OData / WebApi

OData Web API: A server library built upon ODataLib and WebApi
https://docs.microsoft.com/odata
Other
854 stars 475 forks source link

SerializationException: Cannot serialize a null 'Resource' - How can I determine which property is causing this exception #2624

Open bradleypatton opened 2 years ago

bradleypatton commented 2 years ago

Short summary (3-5 sentences) describing the issue. I have an EDM that with one object returns the expected data fine. When I add a second entity to my odata model I get a serialization exception. I've tried to track down where the issue may be but find the root cause. Is there a way to configure the serializer (ie swap it out for Newtonsoft.Json.Net) or track down the property causing the issue.

Assemblies affected

I'm using <PackageReference Include="Microsoft.AspNetCore.OData" Version="8.0.7" />

Reproduce steps

I have a basic EDM

        var odataBuilder = new ODataConventionModelBuilder();
        odataBuilder.EntitySet<ApplicationUser>("Users");
        //odataBuilder.EntitySet<ApplicationAccount>("Accounts");
        return odataBuilder.GetEdmModel();

The Application User object is a subclass of IdentityUser with added properties (I can post that if it helps)

The Users Controller

    [HttpGet]
    [EnableQuery()]
    public async Task<IEnumerable<ApplicationUser>> GetAsync() {
        return DbContext.ApplicationUsers;
    }

When I access http://localhost/odata/Users with the ApplicationAccount line commented out the expected JSON is returned. When I uncomment the ApplicationAccount I can see all of the accounts ( with a similar AccountsController and odata/Accounts ) but now the Users data throws the exception below. I've tried adding Ignore to various properties in the Edm but nothing changes.

Is there a way to track down which property is causing this? Is there a way to use Newtonsoft.Json.Net and configure it to ignore null properties?

Expected result

No exception is thrown

Actual result

System.Runtime.Serialization.SerializationException: Cannot serialize a null 'Resource'. at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteObjectInlineAsync(Object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext) at Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.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.Mvc.Infrastructure.ResourceInvoker.gAwaited|30_0[TFilter,TFilterAsync](ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResultExecutedContextSealed context) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeResultFilters() --- End of stack trace from previous location --- at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.gAwaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.gAwaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope) at Microsoft.AspNetCore.Routing.EndpointMiddleware.gAwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger) at Microsoft.AspNetCore.OData.Routing.ODataRouteDebugMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Authorization.Policy.AuthorizationMiddlewareResultHandler.HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult) at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Session.SessionMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Session.SessionMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.ResponseCompression.ResponseCompressionMiddleware.InvokeCore(HttpContext context) at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

xuzhg commented 2 years ago

@bradleypatton you can create a class derived from ODataResourceSerializer and override WriteObjectInlineAsync to add your verification code

bradleypatton commented 2 years ago

I tried to break the EDM into two separate routes with two separate models:

        services
            .AddControllers()
            .AddOData(opt => opt
                .EnableQueryFeatures(100)
                .AddRouteComponents("oaccounts", GetAccountsModel())
                .AddRouteComponents("ousers", GetUsersModel())
                )
            ...
    private IEdmModel GetAccountsModel() {
        var odataBuilder = new ODataConventionModelBuilder();
        odataBuilder.EntitySet<ApplicationAccount>("Accounts");
        return odataBuilder.GetEdmModel();
    }

    private IEdmModel GetUsersModel() {
        var odataBuilder = new ODataConventionModelBuilder();
        odataBuilder.EntitySet<ApplicationUser>("Users");
        return odataBuilder.GetEdmModel();
    }

Now however I get an exception when browsing to the accounts endpoint (/oaccounts/Accounts) rather than the users one:

SerializationException: Cannot serialize a null 'Resource'. Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteObjectInlineAsync(object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext) Microsoft.AspNetCore.OData.Formatter.Serialization.ODataResourceSerializer.WriteObjectAsync(object graph, Type type, ODataMessageWriter messageWriter, ODataSerializerContext writeContext) Microsoft.AspNetCore.OData.Formatter.ODataOutputFormatterHelper.WriteToStreamAsync(Type type, object value, IEdmModel model, ODataVersion version, Uri baseAddress, MediaTypeHeaderValue contentType, HttpRequest request, IHeaderDictionary requestHeaders, IODataSerializerProvider serializerProvider) Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|30_0<TFilter, TFilterAsync>(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted) ....

An ApplicationAccount has an OwnerId property which is set as a ForeignKey in EfCore model back to the Users table. However even if I set that as Ignored .EntityType.Ignore(u => u.OwnerId) it still throws the exception

habbes commented 2 years ago

@bradleypatton what data do you get when you have the same endpoints but don't use OData? Do you have any user or account entry that is null by any chance. When you're fetching the user entities, do you have any $expand fields? What kind of relationship is there between ApplicationUser and ApplicationAccount?

Can you share what the $metadata endpoint returns and also how the db context and relationships are defined?

bradleypatton commented 2 years ago

So while collecting data to respond to your questions I think I've resolved the issue. However it's not clear why my changes got things working.

Here are the two classes (with comments stripped). Pretty basic POCOs

public class ApplicationUser : IdentityUser {
    public int AccountId { get; set; }

    [MaxLength(250)]
    [Display(Name = "Display Name")]
    public string DisplayName { get; set; }

    public virtual DateTime? LastLoginTime { get; set; }

    public virtual DateTime? CreatedDate { get; set; }

    public string Settings { get; set; }

    [NotMapped]
    public UserSettings UserSettings {
        get { return string.IsNullOrEmpty(Settings) ? new UserSettings() : JsonConvert.DeserializeObject<UserSettings>(Settings); }
        set { Settings = value.Serialize(Formatting.None);  }           
    }
}

public class ApplicationAccount {
    public int ID { get; set; }

    [Required]
    [ForeignKey("Owner")]
    public string OwnerId { get; set; }

    //public ApplicationUser Owner { get; set; }

    [MaxLength(250)]
    public string Name { get; set; }

    public DateTime? CreationDate { get; set; }

    public DateTime? ExpirationDate { get; set; }

    public AccountStatus LicenseStatus { get; set; }

    public AccountLicense LicensePlan { get; set; }

    public int DefaultThemeId { get; set; } = 1;
}

Here's the metadata at each route

<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="Neptune.Web.Data">
            <EntityType Name="ApplicationUser">
                <Key>
                    <PropertyRef Name="Id"/>
                </Key>
                <Property Name="AccountId" Type="Edm.Int32" Nullable="false"/>
                <Property Name="DisplayName" Type="Edm.String" MaxLength="250"/>
                <Property Name="LastLoginTime" Type="Edm.DateTimeOffset"/>
                <Property Name="CreatedDate" Type="Edm.DateTimeOffset"/>
                <Property Name="Settings" Type="Edm.String"/>
                <Property Name="Id" Type="Edm.String" Nullable="false"/>
                <Property Name="UserName" Type="Edm.String"/>
                <Property Name="NormalizedUserName" Type="Edm.String"/>
                <Property Name="Email" Type="Edm.String"/>
                <Property Name="NormalizedEmail" Type="Edm.String"/>
                <Property Name="EmailConfirmed" Type="Edm.Boolean" Nullable="false"/>
                <Property Name="PasswordHash" Type="Edm.String"/>
                <Property Name="SecurityStamp" Type="Edm.String"/>
                <Property Name="ConcurrencyStamp" Type="Edm.String"/>
                <Property Name="PhoneNumber" Type="Edm.String"/>
                <Property Name="PhoneNumberConfirmed" Type="Edm.Boolean" Nullable="false"/>
                <Property Name="TwoFactorEnabled" Type="Edm.Boolean" Nullable="false"/>
                <Property Name="LockoutEnd" Type="Edm.DateTimeOffset"/>
                <Property Name="LockoutEnabled" Type="Edm.Boolean" Nullable="false"/>
                <Property Name="AccessFailedCount" Type="Edm.Int32" Nullable="false"/>
            </EntityType>
        </Schema>
        <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Default">
            <EntityContainer Name="Container">
                <EntitySet Name="Users" EntityType="Neptune.Web.Data.ApplicationUser"/>
            </EntityContainer>
        </Schema>
    </edmx:DataServices>
</edmx:Edmx>

<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="Neptune.Web.Data">
            <EntityType Name="ApplicationAccount">
                <Key>
                    <PropertyRef Name="ID"/>
                </Key>
                <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
                <Property Name="Name" Type="Edm.String" MaxLength="250"/>
                <Property Name="CreationDate" Type="Edm.DateTimeOffset"/>
                <Property Name="ExpirationDate" Type="Edm.DateTimeOffset"/>
                <Property Name="LicenseStatus" Type="Neptune.Web.Data.AccountStatus" Nullable="false"/>
                <Property Name="LicensePlan" Type="Neptune.Web.Data.AccountLicense" Nullable="false"/>
                <Property Name="DefaultThemeId" Type="Edm.Int32" Nullable="false"/>
            </EntityType>
            <EnumType Name="AccountStatus">
                <Member Name="Invalid" Value="0"/>
                <Member Name="FreeTrial" Value="1"/>
                <Member Name="Expired" Value="2"/>
                <Member Name="Paid" Value="3"/>
            </EnumType>
            <EnumType Name="AccountLicense">
                <Member Name="FreeTrial" Value="0"/>
                <Member Name="Personal" Value="1"/>
                <Member Name="Professional" Value="2"/>
                <Member Name="MRX" Value="3"/>
            </EnumType>
        </Schema>
        <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Neptune.Survey">
            <EnumType Name="APILocation">
                <Member Name="US" Value="0"/>
                <Member Name="Canada" Value="1"/>
                <Member Name="EU" Value="2"/>
            </EnumType>
        </Schema>
        <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Default">
            <EntityContainer Name="Container">
                <EntitySet Name="Accounts" EntityType="Neptune.Web.Data.ApplicationAccount"/>
            </EntityContainer>
        </Schema>
    </edmx:DataServices>
</edmx:Edmx>

Both Controllers are basic WebApiControllers and when I browse api/Accounts or api /Users all data is returned correctly. Going through the odata endpoints causes the serialization error

[ApiController]
**[Route("odata")]**
[Route("api/[controller]")]
public class AccountsController : BaseController {
    public AccountsController(ApplicationDbContext context, UserManager<ApplicationUser> manager, ILoggerFactory loggerFactory) :
        base(context, manager, loggerFactory) {
    }

    // GET: api/Accounts
    [HttpGet]
    [EnableQuery()]
    public async Task<IEnumerable<ApplicationAccount>> GetAsync() {
        await PageLoadAsync();
        return DbContext.Accounts;
    }

    // GET: api/Accounts/id [FromODataUri] 
    [HttpGet("{id}")]
    [HttpGet("Accounts({id})")]
    [EnableQuery()]
    public async Task<ApplicationAccount> GetAsync(int id) {
        await PageLoadAsync();
        return await DbContext.Accounts.FindAsync(id);
    }
}

Now notice the [Route("odata")] line. When I comment that out I can navigate to both endpoints and they both return the expected data. I got the idea to add that attribute (and some others that I've already removed) from this blog post https://devblogs.microsoft.com/odata/routing-in-asp-net-core-8-0-preview/ .

Even when that line was in place the $odata page showed oaccounts/Accounts as the endpoint. So I have no idea why a serialization error was being thrown if there incorrect route

Controller & Action HttpMethods Template
Neptune.Web.Controllers.AccountsController.GetAsync (Neptune.Web) GET oaccounts/Accounts

At the end of the day I just want some data shown in a grid component. I've been very frustrated by incomplete documentation, blog posts without of date (ie wrong info) and confusing error messages. I like the idea of odata but it's very hard to get some basic things working.

habbes commented 2 years ago

@bradleypatton thanks for sharing the additional information. I'm going to try and reproduce the issue then get back to you. Meanwhile, I still have a couple more questions:

PS: We recognize that our documentation is in a poor state and are prioritizing an overhaul. We apologize for the frustrating experience.

habbes commented 2 years ago

@bradleypatton I managed to reproduce the issue. It seems that when you apply the Route() attribute to the controller, the requests may get routed to the incorrect Get method. If you have a GetAsync() and GetAsync(string id) and you call GET odata/Users, it appears that the requests gets routed to the GetAsync(string id) overload. The id in this case would be null since it's not provided in the url, so dbContext.FindAsync() returns null and this leads to the serialization error. In your description you didn't show the full definition of the UsersController, I assumed that it has similar methods and attributes as the AccountsController

I'm not sure why this error disappears when you comment out modelBuilder.EntitySet<ApplicationAccount>("Accounts"). I'm still investigating that.

So far, it seems removing the [Route] attribute fixes the issue (the request is routed to the correct method). I think you could also getter more accurate routing by inheriting from ODataController. If you don't want to inherit from ODataController, you can apply the [ODataRouting] attribute to your controller.

You can find a more recent guide to attribute routing in OData here

I will get back to you after investigating the root cause.