OData / AspNetCoreOData

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

Building an EdmModel with inheritance relationship between entities #874

Open orty opened 1 year ago

orty commented 1 year ago

Hi,

I can't seem to find the right way to build my Edm model with the following requirement :

public class EntityA
{
    public Guid Id { get; set; }

    public string PropertyA { get; set; }
}

public class EntityB: EntityA
{
    public string PropertyB { get; set; }
}

When I declare my Edm model using the ODataConventionModelBuilder, I cannot find a way to declare the GetAllB method as a Function on the EntitySet<EntityA> which returns a collection of EntityB. Either I tell it it ReturnsCollection<EntityB>(), and then when the convention builder map the types hierarchy internally, it cannot register EntityB as an EntityType because it is already registered as a ComplexType, or I try to make a call to ReturnsCollectionFromEntitySet<>() but then I have to register a new EntitySet with a different name (which I think I understood will not match anymore with my odata/A/B route ?) or to bind it to the EntitySet<EntityA> and then OData sends me back the wrong type in the response's odata.context property (which I need to be exact to perform some client-side operations for displaying the data).

What did I miss ?

julealgon commented 1 year ago

Can you show your EDM registration code?

If EntityB is being detected as a complex type something is wrong.

gathogojr commented 1 year ago

Hi @orty. Let me know if the following code points you in the right direction

// Controller
public class AController : ODataController
{
    private static readonly List<EntityA> listOfAs = new List<EntityA>
    {
        new EntityA { Id = 1, PropertyA = "PropertyA - 1" },
        new EntityA { Id = 2, PropertyA = "PropertyA - 2" },
        new EntityB { Id = 3, PropertyA = "PropertyA - 3", PropertyB = "PropertyB - 3" },
        new EntityB { Id = 4, PropertyA = "PropertyA - 4", PropertyB = "PropertyB - 4" }
    };

    public ActionResult<IEnumerable<EntityA>> Get()
    {
        return listOfAs.OfType<EntityA>().ToList();
    }

    [HttpGet("prefix/A/B")]
    public ActionResult<IEnumerable<EntityB>> GetB()
    {
        return listOfAs.OfType<EntityB>().ToList();
    }
}

// Edm model
var modelBuilder = new ODataConventionModelBuilder();
var aEntityConfiguration = modelBuilder.EntitySet<EntityA>("A").EntityType;
aEntityConfiguration.Collection.Function("B").ReturnsCollectionFromEntitySet<EntityA>("A");

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: "prefix",
        model: modelBuilder.GetEdmModel()));

To retrieve A collection: /prefix/A To retrieve B collection: /prefix/A/B

NOTE: We're using attribute routing on the GetB function. B alone won't cut it. You need prefix/A/B, or A/B if you have not configured a route prefix.

julealgon commented 1 year ago

I just realized OP used the [controller] replacement in the Route attribute. On that:

orty commented 1 year ago

First things first, thank you for your quick answers :)

I just realized OP used the [controller] replacement in the Route attribute. On that:

Yes it is an issue we ran across, and I omitted many complex things in my current context. For instance, we have two versions of our API ("beta" and "v1") which are based on the namespace the controllers are in. Our route template for each controller is then written as [Route("[namespace]/[controller]")].

This issue has been addressed by implementing the two following classes (I would be glad to reference this in the issue you linked if it can provide any help) (credits to maxc137 from this stackoverflow post)

internal static class ApplicationModelExtensions { public static string GetApiVersion(this ControllerModel controllerModel) => controllerModel.ControllerType.Namespace?.Split('.') .Last() .ToLower();

public static string GetControllerName(this ControllerModel controllerModel) =>
    controllerModel.ControllerName.ToLower();

}


- and a custom `IApplicationModelConvention`
```c#
internal class CustomRouteTokenConvention : IApplicationModelConvention
{
    private readonly string tokenRegex;

    private readonly Func<ControllerModel, string?> valueGenerator;

    public CustomRouteTokenConvention(string tokenName, Func<ControllerModel, string?> valueGenerator)
    {
        this.tokenRegex = $@"(\[{tokenName}])(?<!\[\1(?=]))";
        this.valueGenerator = valueGenerator;
    }

    public void Apply(ApplicationModel application)
    {
        foreach (var controller in application.Controllers)
        {
            string? tokenValue = this.valueGenerator(controller);
            this.UpdateSelectors(
                controller.Selectors,
                tokenValue);
            this.UpdateSelectors(
                controller.Actions.SelectMany(a => a.Selectors),
                tokenValue);
        }
    }

    private string? InsertTokenValue(string? template, string? tokenValue)
    {
        if (template is null)
        {
            return null;
        }

        return Regex.Replace(
            template,
            this.tokenRegex,
            tokenValue ?? string.Empty);
    }

    private void UpdateSelectors(IEnumerable<SelectorModel> selectors, string? tokenValue)
    {
        foreach (var attributeRouteModel in selectors.Select(s => s.AttributeRouteModel))
        {
            if (attributeRouteModel is null)
            {
                continue;
            }

            attributeRouteModel.Template = this.InsertTokenValue(
                attributeRouteModel.Template,
                tokenValue);
            attributeRouteModel.Name = this.InsertTokenValue(
                attributeRouteModel.Name,
                tokenValue);
        }
    }
}

From there, the goal is to dynamically generate the EdmModel for each prefix and register it against the proper value ("beta" or "v1"). The EdmModels themselves are built using information from the controllers' actions metadata, mainly [ProduceResponseType] which we rely on to provide the EntityType or ComplexType returned by any Function or Action (outside of the basic CRUD ones, which seem to be retrieved automatically by the ODataModelConventionBuilder once the EntitySet related to the default [HttpGet] route has been registered).

That being said, it mostly works, but the inital requirement is to have an always (in IT business, == with a minimum of maintenance effort) up-to-date OData $metadata contract which we can rely on, to be able to dynamically build the data display component client-side. I tried the code you provided @gathogojr, and the result seems to be the same as the one I have, since the B function is referenced as having an EntityA return type. Check this out:

{
  "$Version": "4.0",
  "$EntityContainer": "Default.Container",
  "API.Controllers": {
    "EntityA": {
      "$Kind": "EntityType",
      "$Key": [
        "Id"
      ],
      "Id": {
        "$Type": "Edm.Int32"
      },
      "PropertyA": {
        "$Nullable": true
      }
    },
    "EntityB": {
      "$Kind": "EntityType",
      "$BaseType": "API.Controllers.EntityA",
      "PropertyB": {
        "$Nullable": true
      }
    }
  },
  "Default": {
    "B": [
      {
        "$Kind": "Function",
        "$IsBound": true,
        "$Parameter": [
          {
            "$Name": "bindingParameter",
            "$Collection": true,
            "$Type": "API.Controllers.EntityA",
            "$Nullable": true
          }
        ],
        "$ReturnType": {
          "$Collection": true,
          "$Type": "API.Controllers.EntityA",
          "$Nullable": true
        }
      }
    ],
    "Container": {
      "$Kind": "EntityContainer",
      "A": {
        "$Collection": true,
        "$Type": "API.Controllers.EntityA"
      }
    }
  }
}

Here follows the answer from the /poc/A/B endpoint:

{
  "@odata.context": "https://localhost:5001/poc/$metadata#A/API.Controllers.EntityB",
  "value": [
    {
      "@odata.type": "#API.Controllers.EntityB",
      "Id": 3,
      "PropertyA": "PropertyA - 3",
      "PropertyB": "PropertyB - 3"
    },
    {
      "@odata.type": "#API.Controllers.EntityB",
      "Id": 4,
      "PropertyA": "PropertyA - 4",
      "PropertyB": "PropertyB - 4"
    }
  ]
}

I do not see a way to match the $metadata#A/API.Controllers.EntityB value easily to something in the full $metadata contract to parse the available properties. As a nice-to-have feature, I would like eventually to get rid of all fully qualified names in that contract, and show only type names we generate from our automatic model builder (lowercased, shortened, namespace-free).

I trimmed all automatic model generation and took your sample litterally to implement this POC, just within my own project.