OData / AspNetCoreOData

ASP.NET Core OData: A server library built upon ODataLib and ASP.NET Core
Other
447 stars 158 forks source link

Navigation properties are not serialized in operation results #1275

Closed Sneedd closed 1 week ago

Sneedd commented 3 weeks ago

Somehow the OData operations (both, actions and functions) do not return values from navigation properties. I tried lots of different EDM model configuration but just it does not seam to work. Navigation properties are serialized when providing entities for example as a action parameter, but not as a return value.

When I call my action, I get the following result:

POST https://localhost:7068/odata/Countries/GetSpecialResult

{
  "@odata.context": "https://localhost:7068/odata/$metadata#Countries",
  "value": [
    {
      "Id": 1,
      "Name": "USA"
    }
  ]
}

But I would expect the following result:

POST https://localhost:7068/odata/Countries/GetSpecialResult

{
  "@odata.context": "https://localhost:7068/odata/$metadata#Countries",
  "value": [
    {
      "Id": 1,
      "Name": "USA",
      "Cities": [
        { "Id": 1, Name = "New York", CountryId = 1 },
        { "Id": 2, Name = "Los Angeles", CountryId = 1 },
      ]
    }
  ]
}

Here is my example code to reproduce the problem:

// NuGet Dependency used Microsoft.AspNetCore.OData 8.2.5

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData;
using Microsoft.AspNetCore.OData.Formatter;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using Microsoft.OData.ModelBuilder;

public class Country
{
    public int Id { get; set; }
    public string Name { get; set; }

    public List<City> Cities { get; set; } 
}
public class City
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CountryId { get; set; }
}

public class CountriesController : ODataController
{
    [EnableQuery]
    [HttpGet]
    public IActionResult Get()
    {
        var countries = new List<Country>
        {
            new Country { Id = 1, Name = "USA" },
            new Country { Id = 2, Name = "Canada" }
        };
        return this.Ok(countries);
    }

    [HttpPost]
    public IActionResult GetSpecialResult([FromBody] ODataActionParameters parameters)
    {
        return this.Ok(new List<Country>
        {
            new Country 
            { 
                Id = 1, 
                Name = "USA", 
                Cities = new List<City>
                {
                    new City { Id = 1, Name = "New York", CountryId = 1 },
                    new City { Id = 2, Name = "Los Angeles", CountryId = 1 }
                }
            },
        });
    }
}

internal class Program
{
    private static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        var modelBuilder = new ODataConventionModelBuilder();
        var countryEntitySet = modelBuilder.EntitySet<Country>("Countries");
        var countryEntityType = countryEntitySet.EntityType;
        countryEntityType.HasKey(a => a.Id);
        countryEntityType.Property(a => a.Name);
        countryEntityType.HasMany(a => a.Cities);

        var cityEntitySet = modelBuilder.EntitySet<City>("Cities");
        var cityEntityType = cityEntitySet.EntityType;
        cityEntityType.HasKey(a => a.Id);
        cityEntityType.Property(a => a.Name);

        modelBuilder.EntityType<Country>().Collection.Action("GetSpecialResult")
            .ReturnsCollectionFromEntitySet<Country>("Countries");

        builder.Services.AddControllers()
            .AddOData((options, provider) =>
            {
                options.EnableQueryFeatures();
                options.AddRouteComponents("odata", modelBuilder.GetEdmModel());
            })
            .AddJsonOptions(options =>
            {
                options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

        var app = builder.Build();
        app.UseHttpsRedirection();
        app.UseODataRouteDebug();
        app.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(app =>
        {
            app.MapControllers();
        });
        app.Run();
    }
}
julealgon commented 3 weeks ago

What results do you get on your normal "GET" endpoint if you also populate cities there?

Please keep in mind that OData does not automatically expand relationships to non-complex types. Since "City" is an entity in your model with its own ID, it is not a complex type and you need to manually expand the "Cities" collection if you want that in your output, regardless of what you return in the controller.

If you always want to expand the "Cities" property, you can configure it to auto-expand in the EDM setup.

gathogojr commented 2 weeks ago

Thank you @julealgon for the response. @Sneedd Did you have a chance to try out the suggestion by @julealgon?

Sneedd commented 2 weeks ago

Sorry, for my late reply, thought I will have more time but ...

Thanks @julealgon. I will try it with auto-expand and probably with my own serialization if auto-expand is not the right thing.

I hope I will have time for an example this week.

Sneedd commented 1 week ago

Ok I tried the auto-expand functionality but with no luck. I tried to add the AutoExpandAttribute to the navigation property and also I tried to set the AutoExpand property in the EDM. Like this:

[AutoExpand]
public List<City> Cities { get; set; } 

// ... and ...

var countryEntitySet = modelBuilder.EntitySet<Country>("Countries");
var countryEntityType = countryEntitySet.EntityType;
countryEntityType.HasKey(a => a.Id);
countryEntityType.Property(a => a.Name);
countryEntityType.HasMany(a => a.Cities).AutoExpand = true;

I did not find in the OData 4.01 specs that operation responses should behave like this, but I may have missed it.

I never used auto-expand and I did not look into it further. Because I already do some manual OData serialization in the actual project, I will use those helper functions to serialize the response myself. Basically something like this, if I anyone is interessed:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData;
using Microsoft.AspNetCore.OData.Extensions;
using Microsoft.AspNetCore.OData.Formatter;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using Microsoft.OData.ModelBuilder;
using Microsoft.OData.UriParser;
using Newtonsoft.Json.Linq;
using System.Text;

public class Country
{
    public int Id { get; set; }
    public string Name { get; set; }

    [AutoExpand]
    public List<City> Cities { get; set; } 
}
public class City
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CountryId { get; set; }
}

public class CountriesController : ODataController
{
    [EnableQuery]
    [HttpGet]
    public IActionResult Get()
    {
        var countries = new List<Country>
        {
            new Country { Id = 1, Name = "USA" },
            new Country { Id = 2, Name = "Canada" }
        };
        return this.Ok(countries);
    }

    [HttpPost]
    public IActionResult GetSpecialResultByAction([FromBody] ODataActionParameters parameters)
    {
        return this.OkWithExpand(new List<Country>
        {
            new Country 
            { 
                Id = 1, 
                Name = "USA", 
                Cities = new List<City>
                {
                    new City { Id = 1, Name = "New York", CountryId = 1 },
                    new City { Id = 2, Name = "Los Angeles", CountryId = 1 }
                }
            },
        });
    }

    [HttpGet]
    public IActionResult GetSpecialResultByFunction()
    {
        return this.OkWithExpand(new List<Country>
        {
            new Country
            {
                Id = 1,
                Name = "USA",
                Cities = new List<City>
                {
                    new City { Id = 1, Name = "New York", CountryId = 1 },
                    new City { Id = 2, Name = "Los Angeles", CountryId = 1 }
                }
            },
        });
    }

    private IActionResult OkWithExpand(object value)
    {
        var feature = this.HttpContext.ODataFeature();        
        var request = this.HttpContext.Request;
        var context = $"{request.Scheme}://{request.Host.Host}:{request.Host.Port}/{feature.RoutePrefix}$metadata#{((EntitySetSegment)feature.Path.FirstSegment).EntitySet.Name}";
        var json = JToken.FromObject(value);
        var resultJson = new JObject
        {
            new JProperty("@odata.context", context),
            new JProperty("value", json)
        };
        return this.Content(resultJson.ToString(), "application/json", Encoding.UTF8);
    }
}

internal class Program
{
    private static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        var modelBuilder = new ODataConventionModelBuilder();
        var countryEntitySet = modelBuilder.EntitySet<Country>("Countries");
        var countryEntityType = countryEntitySet.EntityType;
        countryEntityType.HasKey(a => a.Id);
        countryEntityType.Property(a => a.Name);
        countryEntityType.HasMany(a => a.Cities).AutoExpand = true;

        var cityEntitySet = modelBuilder.EntitySet<City>("Cities");
        var cityEntityType = cityEntitySet.EntityType;
        cityEntityType.HasKey(a => a.Id);
        cityEntityType.Property(a => a.Name);

        var action = modelBuilder.EntityType<Country>().Collection.Action(nameof(CountriesController.GetSpecialResultByAction));
        action.Parameter<Country>("country");
        action.ReturnsCollectionFromEntitySet<Country>("Countries");

        modelBuilder.EntityType<Country>().Collection.Function(nameof(CountriesController.GetSpecialResultByFunction))
            .ReturnsCollectionFromEntitySet<Country>("Countries");

        builder.Services.AddControllers()
            .AddOData((options, provider) =>
            {
                options.EnableQueryFeatures();
                options.AddRouteComponents("odata", modelBuilder.GetEdmModel());
            })
            .AddJsonOptions(options =>
            {
                options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

        var app = builder.Build();
        app.UseHttpsRedirection();
        app.UseODataRouteDebug();
        app.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(app =>
        {
            app.MapControllers();
        });
        app.Run();
    }
}
julealgon commented 1 week ago

@Sneedd , add [EnableQuery] to your POST action method and it should respect the AutoExpand = true from the EDM setup. I've tested your code with that modification and it works just fine and expands the cities after the POST call:

POST http://localhost:5191/odata/countries/GetSpecialResult
Accept: application/json
{
  "@odata.context": "http://localhost:5191/odata/$metadata#Countries(Cities())",
  "value": [
    {
      "Id": 1,
      "Name": "USA",
      "Cities": [
        {
          "Id": 1,
          "Name": "New York",
          "CountryId": 1
        },
        {
          "Id": 2,
          "Name": "Los Angeles",
          "CountryId": 1
        }
      ]
    }
  ]
}

I would strongly recommend against your custom serialization approach there as the framework natively supports what you are attempting to do.

Sneedd commented 1 week ago

Great, many thanks @julealgon.

With [EnableQuery] also the auto-expand works.

But now also the "normal" query endpoint (GET http://localhost:5191/odata/countries) returns the expanded properties, I guess like it should.

And of course your right, I would also prefer using the existing serialization. In my version I already got the @odata.context wrong.

julealgon commented 1 week ago

@Sneedd

But now also the "normal" query endpoint (GET http://localhost:5191/odata/countries) returns the expanded properties, I guess like it should.

Correct. If you don't want the behavior to apply to the GET method, you'll have to manually expand the results on the POST call (using ?$expand=Cities on the querystring) and remove the AutoExpand setting.

I'm not aware of a way to customize auto-expand on a per-route basis, although it might be possible.