AspNetCoreOData icon indicating copy to clipboard operation
AspNetCoreOData copied to clipboard

Nested Delta<{Complex Type}> in a typed delta payload throws serialization exception

Open gathogojr opened this issue 9 months ago • 0 comments

Assemblies affected

  • ASP.NET Core OData 8.x
  • ASP.NET Core OData 9.x

Describe the bug When a typed delta response payload contains a nested Delta<{Complex Type}>, a serialization exception is thrown

Reproduce steps Consider a simple OData service that processes a delta payload comprising of the following:

Data model:

namespace SampleNs.Models
{
    public class Customer
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public Address StreetAddress { get; set; }
    }

    public class Address
    {
        public string City { get; set; }
    }
}

_Controller:_

```csharp
namespace SampleNs.Controllers
{
    public class CustomersController : ODataController
    {
        public ActionResult PatchAsync([FromBody] DeltaSet<Customer> deltaSet)
        {
            return Ok(deltaSet);
        }
    }
}

Service Configuration:

var builder = WebApplication.CreateBuilder(args);

var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Customer>("Customers");

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        modelBuilder.GetEdmModel()));

var app = builder.Build();

app.UseRouting();
app.MapControllers();

app.Run();

EDM (CSDL) Model

This XML file does not appear to have any style information associated with it. The document tree is shown below.
<edmx:Edmx xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0">
    <edmx:DataServices>
        <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="SampleNs.Models">
            <EntityType Name="Customer">
                <Key>
                    <PropertyRef Name="Id"/>
                </Key>
                <Property Name="Id" Type="Edm.Int32" Nullable="false"/>
                <Property Name="Name" Type="Edm.String" Nullable="false"/>
                <Property Name="StreetAddress" Type="SampleNs.Models.Address" Nullable="false"/>
            </EntityType>
            <ComplexType Name="Address">
                <Property Name="City" Type="Edm.String" Nullable="false"/>
            </ComplexType>
        </Schema>
        <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Default">
            <EntityContainer Name="Container">
                <EntitySet Name="Customers" EntityType="SampleNs.Models.Customer"/>
            </EntityContainer>
        </Schema>
        </edmx:DataServices>
</edmx:Edmx>

Request/Response Uri:

PATCH http://localhost:5013/Customers

Body:

{
  "@odata.context": "http://localhost:5013/$metadata#Customers/$delta",
  "value": [
    {
      "Id": 1,
      "StreetAddress": {
        "City": "London"
      }
    }
  ]
}

Exception:

System.InvalidOperationException: The EDM instance of type '[SampleNs.Models.Address Nullable=True]' is missing the property 'City'. at Microsoft.AspNetCore.OData.Formatter.ResourceContext.GetPropertyValue(String propertyName) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.CreateStructuralProperty(IEdmStructuralProperty structuralProperty, ResourceContext resourceContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.CreateStructuralPropertyBag(SelectExpandNode selectExpandNode, ResourceContext resourceContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.CreateResource(SelectExpandNode selectExpandNode, ResourceContext resourceContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteResourceAsync(Object graph, ODataWriter writer, ODataSerializerContext writeContext, IEdmTypeReference expectedType) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteDeltaObjectInlineAsync(Object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteDeltaComplexAndExpandedNavigationPropertyAsync(IEdmProperty edmProperty, SelectItem selectItem, ResourceContext resourceContext, ODataWriter writer, Type navigationPropertyType) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteDeltaComplexPropertiesAsync(SelectExpandNode selectExpandNode, ResourceContext resourceContext, ODataWriter writer) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteResourceContent(ODataWriter writer, SelectExpandNode selectExpandNode, ResourceContext resourceContext, Boolean isDelta) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteResourceAsync(Object graph, ODataWriter writer, ODataSerializerContext writeContext, IEdmTypeReference expectedType) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteDeltaObjectInlineAsync(Object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataDeltaResourceSetSerializer.WriteDeltaResourceSetAsync(IEnumerable enumerable, IEdmTypeReference feedType, ODataWriter writer, ODataSerializerContext writeContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataDeltaResourceSetSerializer.WriteObjectInlineAsync(Object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataDeltaResourceSetSerializer.WriteObjectAsync(Object graph, Type type, ODataMessageWriter messageWriter, ODataSerializerContext writeContext) at Microsoft.AspNetCore.OData.Formatter.ODataOutputFormatterHelper.WriteToStreamAsync(Type type, Object value, IEdmModel model, ODataVersion version, Uri baseAddress, MediaTypeHeaderValue contentType, HttpRequest request, IHeaderDictionary requestHeaders, IODataSerializerProvider serializerProvider) at Microsoft.AspNetCore.OData.Formatter.ODataOutputFormatterHelper.WriteToStreamAsync(Type type, Object value, IEdmModel model, ODataVersion version, Uri baseAddress, MediaTypeHeaderValue contentType, HttpRequest request, IHeaderDictionary requestHeaders, IODataSerializerProvider serializerProvider) at Microsoft.AspNetCore.OData.Formatter.ODataOutputFormatter.WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeResultAsync>g__Logged|22_0(ResourceInvoker invoker, IActionResult result) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeResultFilters>g__Awaited|28_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker) at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context) Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware: Warning: The response has already started, the error page middleware will not be executed. Microsoft.AspNetCore.Server.Kestrel: Error: Connection id "0HNALEG08BM4D", Request id "0HNALEG08BM4D:00000001": An unhandled exception was thrown by the application.

Expected behavior Expected response to be returned:

{
  "@odata.context": "http://localhost:5013/$metadata#Customers/$delta",
  "value": [
    {
      "Id": 1,
      "StreetAddress": {
        "City": "London"
      }
    }
  ]
}

Screenshots Deserialization is successful:

Image

Additional context The issue is related to this line here: https://github.com/OData/AspNetCoreOData/blob/c60d9e0e9f68d23ee8ccd84e854ef7e430fe6edb/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs#L206

navigationPropertyType is passed into the function and it has a default value of null. For single-valued delta complex properties, it's not passed

ODataSerializerContext.Type is used by IsDeltaOf property to check if we're dealing with a delta at this point: https://github.com/OData/AspNetCoreOData/blob/c60d9e0e9f68d23ee8ccd84e854ef7e430fe6edb/src/Microsoft.AspNetCore.OData/Formatter/ResourceContext.cs#L163

So what will happen is that since Type will be null, the logic in ResourceContext.GetPropertyValue breaks and an exception is thrown that the property couldn't be found on the type, even when the property exists.

Changing the line I referenced as follows fixes the issue.

nestedWriteContext.Type = navigationPropertyType ?? propertyValue.GetType();

This works because we're able to later evaluate that it's a DeltaOfT object.

We need to determine if this is the best way to fix the issue. There could be room for further improvement. We call deltaNestedProperties.TryGetValue method to retrieve the value for the nested resource here: https://github.com/OData/AspNetCoreOData/blob/c60d9e0e9f68d23ee8ccd84e854ef7e430fe6edb/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs#L304

When then call obj.GetType() to get the type of the value, that type we later pass onto WriteDeltaComplexAndExpandedNavigationPropertyAsync, where we later make a second call to retrieve the nested resource value at this point: https://github.com/OData/AspNetCoreOData/blob/c60d9e0e9f68d23ee8ccd84e854ef7e430fe6edb/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs#L179.

The property value that is returned would give us the same type that was passed into the method... It's not clear why only the navigation property type is passed, and why this was not expanded to {nested resource type} so it covers both complex and navigation properties.

gathogojr avatar Feb 25 '25 08:02 gathogojr