Description
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.g__Logged|22_0(ResourceInvoker invoker, IActionResult result)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|28_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.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:
Additional context
The issue is related to this line here:
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:
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:
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:
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.