Routing in ASP.NET Core OData 8.0 Preview

Sam Xu

Sam

Introduction

In the previous version of ASP.NET Core OData, such as 6.x and 7.x version, the OData routing is IRouter-based Web API Routing, that is, OData router is a Router implementing IRouter interface. Even in the ASP.NET Core OData 7.x Endpoint Routing, it is also related to the IRouter routing. Since 8.0, we want to build the OData routing really on ASP.NET Core Endpoint Routing and make the OData routing more “positive” as much as possible.

In this post, we would like to share more details about the design and implementation of OData Routing in 8.0 preview. Since it’s preview, we are wishfully looking forward to any feedback, requirement to improve the design and implementation.

Register OData Endpoint Routing

As mentioned in Routing section, OData routing takes the responsibility to match the incoming HTTP requests and to dispatch those requests to the app’s executable endpoints, especially the action in the OData controller. That is, when the client issues a request to an OData service, the ASP.NET Core OData framework will map the request to an action in the OData controller. Such mapping is based on the following configuration.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRouting();
        services.AddOData(opt => opt
            .AddModel("v1", GetEdmModel1()).
            .AddModel("v2{version}", GetEdmModel2()));
        //…
    }
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

Where,

  1. AddOData() method is used to register a set of OData services, including OData routing services. The routing services are used to build a mapping between the endpoint and the OData path template.
  2. AddModel() method is used to config a mapping between the prefix and the Edm model. In the above configuration, we have the following “prefix” setting:
    • “v1”
    • “v2{version}”

Endpoint in ASP.NET Core OData

From ASP.NET Core doc, an endpoint is something that can be:

  • Selected, by matching the URL and HTTP method.
  • Executed, by running the delegate.

In ASP.NET Core OData, an endpoint is an executable action defined on the OData controller. For example, we have the following “CustomersController”.

public class CustomersController : ControllerBase
{
    [HttpGet]
    [EnableQuery]
    public IEnumerable<Customer> Get()
    {
        //…
    }

    [HttpGet]
    [EnableQuery]
    public Customer Get(int key)
    {
        //…
    }
}

Where, Get() and Get(key) are both endpoints that can be selected and executed for a certain set of OData request. More precisely ,

  1. Get() could be selected and executed by OData path templates:
    • ~/Customers
    • ~/Customers/$count
  1. Get(key) could be selected and executed by OData path templates:
    • ~/Customers({key})
    • ~/Customers/{key}

OData Routing in 8.0 is designed and implemented to build a relationship between the action of the controller (endpoints) and the OData routing template. More detail, OData routing builds OData path templates for all potential endpoints. ASP.NET Core matches the incoming HTTP requests using the OData path templates and dispatches those requests to the associated endpoints, i.e., the action in the OData controller.

The construction of the relationship between endpoints and OData routing template is based on a set of rules, such rules are called OData Routing Convention. For example, “CustomersController” is an OData controller when the controller name “Customers” is an entity set in a given Edm model. “EntitySetName + Controller” is one of the OData controller name convention.

OData URL components

Before move to detail implementation of OData routing, it’s helpful to understand the OData URI components in ASP.NET Core OData. A HTTP request URL used by ASP.NET Core OData has at most four significant parts as below:

A picture containing diagram Description automatically generated

Where,

  1. Root URL is the root of an OData service.
  2. Route prefix is the route prefix template added before the OData path. It could be empty.
  3. OData resource path is the OData path constructed based on rules defined in OData URL convention Spec.
  4. OData query options are the query options used in OData request to control the data returned from the resource identified the URL, for example the amount and order of the data. It includes system query options, CustomQueryOptions and parameter aliases.

Basically, the URL part before the OData resource path is considered as service root URL. That is, service root is the combination of root URL and route prefix if the route prefix is configured.

OData Routing

As mentioned, OData routing services are registered in startup and are used to build a mapping between the endpoint and the OData path template.

Basically, there are two ways to build the OData path templates to endpoints:

  1. Convention Routing: It uses a set of pre-defined rules to add the OData path templates to the endpoints.
  2. Attribute Routing: It uses OData routing attributes to add the OData path templates to the endpoints.

Convention Routing

Convention routing is using a set of pre-defined rules to build the OData path template for an endpoint, i.e, the action in the controller. Let us re-use the following codes as an example to illustrate:

// Controller
public class CustomersController : ControllerBase
{
    [HttpGet]
    [EnableQuery]
    public IEnumerable<Customer> Get()
    {
        //…
    }

    [HttpGet]
    [EnableQuery]
    public Customer Get(int key)
    {
        //…
    }
}

// Startup.cs
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // …
        services.AddOData(opt => opt.AddModel("v{version}", GetEdmModel());
    }

    // … skip the Configure(…)

    private static IEdmModel GetEdmModel()
    {
        var builder = new ODataConventionModelBuilder();
        builder.EntitySet<Customer>(“Customers”);
        return builder.GetEdmModel();
    }
}

// Customer class
public class Customer
{
    public int Id { get;set }
}

AddOData” in the startup registers an entity set rule (implemented as EntitySetRoutingConvention) which creates the following OData path templates for “Get()” method in “CustomersController”.

  • v{version}/Customers
  • v{version}/Customers/$count

Meanwhile, “AddOData” also registers an entity rule (implemented as EntityRoutingConvention) which creates the following OData path templates for “Get(int key)” method in “CustomersController”.

  • v{version}/Customers({key})
  • v{version}/Customers/{key}

Based on the generated path template, ASP.NET Core can match and dispatch the following requests to the correct endpoints (actions in the controller), for example:

  1. http://localhost/v2/Customers/$count to “Get()
  2. http://localhost/vbeta/Customers/2 to “Get(int key)

Built-in conventional routings

EntitySetRoutingConvention” and “EntityRoutingConvention” are part of the built-in conventional routings. We have the following whole convention routings.

  1. MetadataRoutingConvention (0)
  2. EntitySetRoutingConvention (100)
  3. SingletonRoutingConvention (200)
  4. EntityRoutingConvention (300)
  5. PropertyRoutingConvention (400)
  6. NavigationRoutignConvention (500)
  7. FunctionRoutingConvention (600)
  8. ActionRoutingConvention (700)
  9. OperationImportRoutingConvention (900)
  10. RefRoutingConvention (1000)

All of the above conventional routing classes implement the following interface:

public interface IODataControllerActionConvention
{
    int Order { get; }
    bool AppliesToController(ODataControllerActionContext context);
    bool AppliesToAction(ODataControllerActionContext context);
}

Where:

  1. Order is used to order by the conventions. Each conventional routing class has a certain order value.(See the integer in the parentheses after the class name, be noted, preview 1 version has wrong order value). The convention with lower order value will execute first.
  2. AppliesToController is used to identify whether this convention can apply to the input controller. If it returns true, which means this convention can apply to all actions in this controller. Otherwise, it means this convention can’t apply to any action in this controller.
  3. AppliesToAction can run on all “actions” on this controller, only if “AppliesToController” returns true. It can return true, which means this action has been processed by this convention. So, all the remaining conventions (with higher order) should skip in the queue (only for this action). If It returns false, which means this action has not been processed by this convention. So, the remaining conventions can continuously run on this action.

ODataControllerActionContext

Frequently to test a controller whether is an OData controller or not could be time consuming. ODataControllerActionContext is designed to cache information for conventional routings, which could be generated multiple times, for example to find entity set or singleton from the Edm model.

public class ODataControllerActionContext
{
    string Prefix { get; }
    IEdmModel Model { get; }
    IEdmEntitySet EntitySet { get; }
    IEdmSingleton Singleton { get; }
    ControllerModel Controller { get;}
    ActionModel Action { get; }
}

Be noted, the context is used both AppliesToController and AppliesToAction. The “Action” property in the context is only available in AppliesToAction.

Attribute routing

Although there are 10 built-in conventional routings, all of them only cover a considerably basic part of OData routing conventions defined in OData URL convention spec. In ASP.NET Core OData, we also include the attribute routing to support the routings not covered in conventional routing. For example, you can easily use attribute routing to route the following Uri:

~/odata/Customers({key})/Orders({relatedKey})/Price

Attribute routing uses two attributes to find controller and action. One is ODataPrefixAttribute, the other is ODataRouteAttribute. In fact, attribute routing is also a “conventional routing”, because the template string in the attribute should follow up the OData URL convention. Therefore, we have the following built-in attribute routing class definition:

public class AttributeRoutingConvention : IODataControllerActionConvention
{
    public virtual int Order => -100;
    // …
}

Where, the order of attribute routing convention is -100. This is the lowest order in all built-in conventions. It means attribute routing convention runs first.

Attribute routing is enabled by default. You can disable it through the “ODataOptions” when calling “AddOData()“.

Attributes in attribute routing

We use the following two attributes combined together to define an attribute routing:

  1. ODataRoutePrefixAttribute is an attribute that can, and only can be placed on an OData controller to specify the prefix that will be used for all actions of that controller.
  2. ODataRouteAttribute is an attribute that can, and only can be placed on an action of an OData controller to specify the OData URLs that the action handles.

Below is an example :

[ODataRoutePrefix("Customers({id})")]
public class AnyControllerNameHereController : ODataController
{
    [ODataRoute("Address")]
    public IHttpActionResult GetAddress(int id)
    {
        //......
    }

    [ODataRoute("Address/City")]
    public IHttpActionResult GetCity(int id)
    {
        //......
    }
}

Attribute route template

The route template for an action in attribute routing is combined with the ODataRoutePrefixAttribute on controller and ODataRouteAttribute on the action. So,

The above “GetCity(int key)” has the “Customers({id})/Address/City” route template.

RoutePrefix” property both in ODataRoutePrefixAttribute and ODataRouteAttribute is used to specify the route which can be applied to this template.

For example:

[ODataRoute("Customers({id})/Address", "odata")]
public IHttpActionResult GetAddress(int id) {}

GetAddress() only has the template as “odata/Customers({id})/Address“.

Meanwhile,

[ODataRoute("Address/City", "v1{version}")]
public IHttpActionResult GetCity(int id) {}

GetCity() only has the template as “v1{version}/Customers({id})/Address/City“.

Without providing “RoutePrefix” means such template can apply to all route prefixes.

Customize routing convention

We want to make developer to customize his own routing convention more easy. Developer can derive from any built-in convention to start his own convention:

public class MyEntitySetRoutingConvention : EntitySetRoutingConvention
{
    public override Order = base.Order - 10
}

Or, developer can start from scratch by implementing “IODataControllerActionConvention“.

public class MyEntitySetRoutingConvention : IODataControllerActionConvention
{
    public Order = 11;
}

Developer can set the “Order” value correctly to put his convention before a certain convention and after.

Developer can call the following codes to register the customized routing convention:

services.TryAddEnumerable(
    ServiceDescriptor.Transient<IODataControllerActionConvention, MyEntitySetRoutingConvention>());

There is an extension method to help developer register his convention more easy:

public static IODataBuilder AddConvention<T>(this IODataBuilder builder) where T : class, IODataControllerActionConvention

OData routing metadata

No matter conventional routing or attribute routing, an OData routing metadata is added into the action. OData routing metadata is an instance which implements the following interface:

public interface IODataRoutingMetadata
{
    string Prefix { get; }
    IEdmModel Model { get; }
    ISet<string> HttpMethods { get; }
    ODataPathTemplate Template { get; }
}

Where,

  1. Prefix is the routing prefix.
  2. Model is the Edm model associated with this routing prefix.
  3. HttpMethods is the accepted Http Methods
  4. Template is the OData path template.

You can use the following method to retrieve the routing metadata on a selected endpoint.

IODataRoutingMetadata metadata = endpoint.Metadata.GetMetadata<IODataRoutingMetadata>();

In the “ASP.NET Core OData 8.0 Preview for .NET 5“, We have the following debug information that illustrates the OData routing metadata.

ODataPathTemplate is a collection of ODataSegmentTemplate, which is used to generate the ODataPath based on the route values.

For example, The above ODataPathTemplate can generate a ODataPath as follows for the request http://localhost/vbeta/Customers(42):

  1. EntitySetSegment (“Customers”)
  2. KeySegment (“Id=42”)

However, you can inject your own “IODataTemplateTranslator” to do the OData path template translation.

Summary

This post is an introduction about routing in ASP.NET Core OData 8.0 preview. Hope it can help you understand the OData routing. However, to improve OData routing is never be stopped. We are still looking forward to feedbacks, requirements and concerns to improve the routing design and implementation. Please do not hesitate to try and let me know your thoughts through saxu@microsoft.com. Thanks.

7 comments

Leave a comment

  • Avatar
    David Taylor

    Hey Sam. This is all great work and I really appreciate everything the team is doing.

    Can I “please please” ask if the team can natively support the OData v4 $search option, where it is up to the server to determine how to interpret and implement the search. Salesforce supports this feature and sends the search text through to the server. I would really like (without a hack) to implement my own search on my server-side using this part of the standard. Unfortunately you cannot reconfigure salesforce to send $search as another querystring parameter (like “search” instead), it needs to be $search as per the standard. I really don’t want to do URL rewriting or anything else just to get around the fact your team has not implemented it yet. Please Please 😉

  • Diego van Haaster
    Diego van Haaster

    Hi Sam
    Thanks for the post and for the work in the library.
    I recently installed the 8-preview version and so far I can’t get the routing for entities with composite key to work correctly.
    For methods of the form Get (int keyId1, keyId2) the requests to /entitySet(Id1=1, Id2=2) always return a 404.
    I appreciate any information that helps me see how to solve this problem, I will try to create my own convention.