Open bradleypatton opened 2 years ago
@bradleypatton you can create a class derived from ODataResourceSerializer and override WriteObjectInlineAsync
to add your verification code
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.
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
@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?
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.
@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:
null
items in the collection by any chance?ApplicationUser Owner
property in the ApplicationAccount
class?DbContext
and basically any other information that would help reproduce the issuePS: We recognize that our documentation is in a poor state and are prioritizing an overhaul. We apologize for the frustrating experience.
@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.
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
The Application User object is a subclass of IdentityUser with added properties (I can post that if it helps)
The Users Controller
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.g Awaited|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.g AwaitRequestTask|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)