Attribute Routing in ASP.NET Core OData 8.0 RC

Sam Xu

Introduction

Attribute routing is how Web API matches the incoming HTTP requests to an action based on route template attributes decorated on controller or action. ASP.NET Core defines a set of route template attributes to enable attribute routing, such as RouteAttribute, HttpGetAttribute etc. ASP.NET Core OData 8.0 RC supports these attributes to enable you to define OData attribute routing endpoints.

In this post, I would like to share details about the changes and usages of the attribute routing in OData ASP.NET Core OData 8.0 RC. The code snippets in this post are from this sample project. Please try and let me know your thoughts.

No ODataRouteAttribute and ODataRoutePrefixAttribute

In the “history” of ASP.NET Core OData, such as 6.x and 7.x version, OData attribute routing uses two attributes to find controller and action. One is ODataRoutePrefixAttribute, the other is ODataRouteAttribute. Here’s a basic usage to define an OData attribute routing:

[ODataRoute("Books({key})")]
public IActionResult Get(int key)
{
    …
}

In ASP.NET Core OData 8.0 RC, these two attributes are gone.

Instead, OData attribute routing is changed to use ASP.NET Core route template attribute classes. ASP.NET Core has the following route template attributes:

Switch to use ASP.NET Core route attributes is straightforward. Here’s the same OData route template using HttpGetAttribute:

[ODataRoute("Books({key})")]
[HttpGet("Books({key})")]
public IActionResult Get(int key)
{
    …
}

Be noted, Head HTTP method is not supported yet in RC.

ODataRoutingAttribute

To enable OData attribute routing to work, either controller or action should decorate an attribute named ODataRoutingAttribute. [It’s renamed as ODataAttributeRoutingAttribute in 8.0.]

ODataRoutingAttribute is introduced in RC version to avoid polluting other ASP.NET Core route templates, since OData attribute routing is enabled by default if call AddOData().

ODataRoutingAttribute can be used on controller and action. If we decorate it on the controller, all actions in this controller are considered as OData actions. That means the attribute routing convention will parse all routing templates as OData attribute routing. For example:

[ODataRouting]
public class HandleCustomerController : Controller
{
…
}

We can decorate a specific action using ODataRoutingAttribute as:

public class HandleOthersController : Controller
{
    [ODataRouting]
    [HttpGet("odata/Orders/{key}")]
    public IActionResult Get(int key)
    {
        return Ok($"Orders{key} from OData");
    }

    [HttpGet("odata/Orders({key})")]
    public IActionResult GetOrder(int key)
    {
        return Ok($"Orders{key} from non-OData");
    }
}

Where:

  1. Get(int key) is built as OData endpoint, because it’s decorated using [ODataRouting] and the route template “Orders/{key}” is a valid OData template.
  2. GetOrder(int key) is not built as OData endpoint, it will go to normal ASP.NET Core routing.

If you run and test using following requests:

1) GET http://localhost:5000/odata/Orders/2

The response is OData payload:

{
  "@odata.context": "http://localhost:5000/odata/$metadata#Edm.String",
  "value": "Orders2 from OData"
}

2) GET http://localhost:5000/odata/Orders(3)

The response is a plain text string:

Orders3 from non-OData

ODataController

ODataController has ODataRoutingAttribute decorated as:

[ODataRouting]
public abstract class ODataController : ControllerBase
{}

So, to create your own controller and derived it from ODataController is a common way to use OData attribute routing. Here is an example:

public class HandleBookController : ODataController
{}

Starting from next section, let’s review some OData attribute routing scenarios.

Attribute routing using Http Verb attributes

The basic attribute routing scenario is to use HTTP Verb attributes directly.

Let us have the following controller as example (Be noted, we use ODataController directly):

public class HandleBookController : ODataController
{
    [EnableQuery(PageSize = 1)]
    [HttpGet("odata/Books")]
    [HttpGet("odata/Books/$count")]
    public IActionResult Get()
    {
       return Ok(_db.Books);
    }

    [EnableQuery]
    [HttpGet("odata/Books({id})")]
    [HttpGet("odata/Books/{id}")]
    public IActionResult Get(int id)
    {
        return Ok(_db.Books.FirstOrDefault(c => c.Id == id));
    }
}

In the above codes, where:

  • Each Get action contains two [HttpGet] attributes with route templates. Each [HttpGet] matches GET HTTP requests only based on the route template.
  • Each route template on the first Get() action includes string literal only, such as, “odata”, “Books” and “$count”. Particularly, “odata” is the route prefix defined in startup.cs. “Books” is OData entity set name, and “$count” is OData count segment.
  • Each route template on the second Get(int id) action includes {id} route template parameter. Therefore, the “id” value in the request is binded to int id parameter.

Based on the preceding route templates,

  • GET ~/odata/Books matches the first Get action.
  • GET ~/odata/Books(3) matches the second Get action and binds the key value 3 to the id parameter.

Be noted, don’t forget to append the route prefix when you construct the route template.

Attribute routing using Http Verb and Route attributes

We can decorate RouteAttribute on action and combine it with Http Verb attributes.

Let us have the following controller as example:

public class HandleBookController : ODataController
{
    [Route("odata/Books({key})")]
    [HttpPatch]
    [HttpDelete]
    public IActionResult OperationBook(int key)
    {
       // the return is just for test.
       return Ok(_db.Books.FirstOrDefault(c => c.Id == key));
    }
}

In this controller, OperationBook(int key) has two HTTP Verb attributes, [HttpPatch] and [HttpDelete]. Both have null route template. It also has a RouteAttribute with route template string. Therefore, [Route(“odata/Books({key})”)] is combined with patch and delete verb attributes to construct the following route template (From the sample, you can send “~/$odata” to get the following debug information):

Based on the route template, The Uri path Patch ~/odata/Books(2) can match this action and bind the key value 2 to the key parameter.

Attribute routing using RouteAttribute on controller

We can decorate RouteAttribute on the controller. The route template in [Route] attribute is combined with route template on the individual action in that controller. The route template of RouteAttribute is prepended before route template on the action to form the final route template for that action.

Let us have the following controller as example (be noted, I use ODataRouting on the controller):

[ODataRouting]
[Route("v{version}")]
public class HandleCustomerController : Controller
{
    [HttpGet("Customers")]
    [HttpGet("Customers/$count")]
    public IActionResult Get(string version)
    {
        return Ok(_db.Customers);
    }

    [HttpGet("Customers/{key}/Default.PlayPiano(kind={kind},name={name})")]
    [HttpGet("Customers/{key}/PlayPiano(kind={kind},name={name})")]
    public string LetUsPlayPiano(string version, int key, int kind, string name)
    {
        return $"[{data}], Customer {key} is playing Piano (kind={kind},name={name}";
    }
}

Where:

  1. HandleCustomerController has RouteAttribute, its route template string “v{version}” is prepended to route template on each individual action.
  2. Get(string version) has two [HttpGet] attributes, the route template in each [HttpGet] combines with the route template on the controller to build the following attribute routing templates:
    • ~/v{version}/Customers
    • ~/v{version}/Customers/$count
  3. LetUsPlayPiano(…) has two [HttpGet] attributes, the route template in each [HttpGet] combines with the route template on the controller to build the following attribute routing templates:
    • ~/v{version}/Customers/{key}/Default.PlayPiano(kind={kind},name={name})
    • ~/v{version}/Customers/{key}/PlayPiano(kind={kind},name={name})

Based on the attribute routing templates:

  • The URL path “GET ~/v2/Customers” matches Get(string version), where the value of version parameter is “2”.
  • The URL path “GET ~/v2/Customers/3/PlayPiano(kind=4,name=’Yep’)” matches LetUsPlayPiano(version, key, kind, name), where version=”2″, key=3, kind=4 and name=”Yep”.

Multiple RouteAttribute routes

We can also decorate multiple RouteAttribute on the controller. It means that route template of each [Route] combines with each of the route template of attributes on the action methods:

Let us have the following controller as example (be noted, I use ODataRouting on the action):

[Route("odata")]
[Route("v{version}")]
public class HandleMultipleController: Controller
{
    [ODataRouting]
    [HttpGet("orders")]
    public string Get(string version)
    {
        if (version != null)
        {
           return $"Orders from version = {version}";
        }
        else
        {
            return "Orders from odata";
        }
    }
}

So, Get(string version) has two attribute routing templates:

  • GET ~/odata/orders
  • GET ~/v{version}/orders

Based on the implementation of Get(string version), we can test it using following requests:

1) GET http://localhost:5000/odata/Orders

The response is:

{
  "@odata.context": "http://localhost:5000/odata/$metadata#Edm.String",
  "value": "Orders from odata"
}

2) GET http://localhost:5000/v2001/Orders

The response is:

{
  "@odata.context": "http://localhost:5000/v2001/$metadata#Edm.String",
  "value": "Orders from version = 2001"
}

Suppress RouteAttribute on controller

We can use “/” to suppress prepending the RouteAttribute on controller to individual action.

Let us have the following controller as example:

[Route("v{version}")]
public class HandAbolusteController: Controller
{
    [ODataRouting]
    [HttpGet("/odata/orders({key})/SendTo(lat={lat},lon={lon})")]
    public string SendTo(int key, double lat, double lon)
    {
        return $"Send Order({key}) to location at ({lat},{lon})";
    }
}

Where, SendTo(…) action has one route template as:

~/odata/orders({key})/SendTo(lat={lat},lon={lon})

Clearly, “v{version}” in [Route("v{version}")] doesn’t prepend to [HttpGet] attribute template.

Known issue: If we use two [Route(…)] on HandAbolusteController, SendTo will have two selector models associated and ASP.NET Core throws ambiguous selector exception. It’s a known issue and will fix in the next version.

Other Attributes

We can use [NonODataController] and [NonODataAction] to exclude certain controller or action out of attribute routing. [Both are replaced by ODataIgnoredAttribute in 8.0.]

Besides, [ODataModelAttribute] (renamed as ODataRouteComponentAttribute in 8.0) has no effect to attribute routing, it’s only for the conventional routing to specify the route prefix. In attribute routing, we put the route prefix in the route template directly, either using [Route] attribute or prepend the route prefix before the route template, such as “odata” prefix in route template “odata/Books”.

We can also disable the attribute routing globally using EnableAttributeRouting property on ODataOptions.

services.AddOData(opt => opt.EnableAttributeRouting = false);

Route template parser

As mentioned in Routing in ASP.NET Core OData 8.0 Preview, OData attribute routing is also a “conventional routing”, because the template string in the attribute should follow up the OData URL convention. Here’s the definition of AttributeRoutingConvention:

public class AttributeRoutingConvention : IODataControllerActionConvention
{
    public AttributeRoutingConvention(ILogger<AttributeRoutingConvention> logger,
       IODataPathTemplateParser parser)
    { ... }

    public virtual int Order => -100;
    // … 
}

Where, IODataPathTemplateParser interface is a route template parser which is how OData parses and understands the route template string.

public interface IODataPathTemplateParser
{
    ODataPathTemplate Parse(IEdmModel model, string odataPath, IServiceProvider requestProvider);
}

IODataPathTemplateParser is registered in the service provider. It can inject into the constructor of AttributeRoutingConvention. The default route template parser uses the built-in OData Uri parser to parse the route template path. If it can’t meet your requirement, you can create your own template parser to overwrite the default one.

Summary

Attribute routing enables you to achieve more routings by constructing basic and advanced OData routing templates. Moreover, you can mix it with conventional routing to achieve more. Again, to improve OData Web API routing is never 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.

Great thanks for Javier Calvarro Nelson and David Fowler.

11 comments

Discussion is closed. Login to edit/delete existing comments.

  • Adam Caviness 0

    This is a very competent post with good detail. Thanks for giving us a peek into 8 RC. I REALLY wish the OData Client could get the same love that the server is getting. There’s so much untapped potential in the Client’s Linq provider. Thank you!

  • Dilshod Komilov 0

    I like it. I tried to migrate to this version but I am getting problems with open types. I ignored all Dictionaries but it is throwing error on sending request to Get action. Here is the model:
    public class Student : EntityBase
    {
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Score { get; set; }
    }

    public abstract class EntityBase
    {
    public Dictionary Test { get; set; }
    public Dictionary Test2 { get; set; }
    }
    static IEdmModel GetEdmModel()
    {
    var odataBuilder = new ODataConventionModelBuilder();
    odataBuilder.EntitySet(“Student”);
    var entity = odataBuilder.EntityType();
    entity.Ignore(s => s.Test);
    entity.Ignore(s => s.Test2);

    return odataBuilder.GetEdmModel();
    }
    I am getting this error:

    System.ArgumentException: Found more than one dynamic property container in type ‘Student’. Each open type must have at most one dynamic property container. (Parameter ‘propertyInfo’)
    at Microsoft.OData.ModelBuilder.StructuralTypeConfiguration.AddDynamicPropertyDictionary(PropertyInfo propertyInfo)
    at Microsoft.OData.ModelBuilder.ODataConventionModelBuilder.MapStructuralType(StructuralTypeConfiguration structuralType)
    at Microsoft.OData.ModelBuilder.ODataConventionModelBuilder.MapTypes()
    at Microsoft.OData.ModelBuilder.ODataConventionModelBuilder.GetEdmModel()
    at Microsoft.AspNetCore.OData.Extensions.ActionDescriptorExtensions.GetEdmModel(ActionDescriptor actionDescriptor, HttpRequest request, Type entityClrType)
    at Microsoft.AspNetCore.OData.Query.ODataQueryParameterBindingAttribute.ODataQueryParameterBinding.BindModelAsync(ModelBindingContext bindingContext)
    at Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BinderTypeModelBinder.BindModelAsync(ModelBindingContext bindingContext)
    at Microsoft.AspNetCore.Mvc.ModelBinding.ParameterBinder.Bin

    I really like this, it would be good if this problem will be solved

    • Dilshod Komilov 0

      Also I am getting error:The property ‘Name’ cannot be used in the $select query option. on simple odata select query

      • Sam XuMicrosoft employee 0

        @Dilshod Komilov

        Thanks for trying it. Your feedback is valuable.
        For open type, OData convention model builds the CLR type with IDictionary property as open edm type. Such property is used as container for the dynamic properties. One open type or its base type can have and only have one container.

        Your ‘EntityBase’ has two dictionary properties. I don’t know whether its type is IDictioanary or not. If it’s the case, you should remove one.
        For the query option, did you enable the $select option? You can enable it when call “AddOData”.

        Hope it can help you. If you still facing problem. Would you please share a repro for me to dig? You can share a github repo.
        Thanks.

  • Jairo Martins Marques 0

    Hi,

    I just upgrade to rc-2 and I’m getting this error:

    /Users/Jairo/Projects/Direction/Direction.Framework.V3/Backend/src/Direction.Services/Start/Startup.cs(61,17): error CS1929: ‘IServiceCollection’ does not contain a definition for ‘AddOData’ and the best extension method overload ‘ODataMvcBuilderExtensions.AddOData(IMvcBuilder, Action)’ requires a receiver of type ‘IMvcBuilder’ [/Users/Jairo/Projects/Direction/Direction.Framework.V3/Backend/src/Direction.Services/Direction.Services.csproj]

    It’s is in my Startup code:

    // Initialize OData maps View x Controllers Name
    services.AddOData(opt => opt.AddModel("odata",
        NativeInjectorBootStrapper.GetEdmModel())
       .Filter().Count().Expand().OrderBy().Select().SetMaxTop(null));

    Any suggestion how to fix it?

    I’m using:

    using Microsoft.AspNetCore.OData;

    • Sam XuMicrosoft employee 1

      @Jairo Martins Margues

      I made a breaking change for the public API “AddOData” in rc2.
      AddOData is changed from extensions on ISerivceCollection to extension on IMvc(Core)Builder.
      The migration is easy by calling AddControllers() first, then calling AddOData().

      And Thanks @Darragh Jones

      • Jairo Martins Marques 0

        Nice!

  • Rodrigo Liberoff Vázquez 0

    Hi!

    Amazing post!

    How does this new version works with OData Versioning (for instance, with the “Microsoft.AspNetCore.OData.Versioning” package)?

    I’ve been trying to make the routing work parsing different versions, but I’ve been unable to do so.

    I’m using namespace versioning convention, therefore I have two controllers with the same class name in two different namespaces (Controllers.OData.V1.MyController, Controllers.OData.V2.MyController). Each one has its own EdmModel. The routing template is for both the same: “odata/v{version:apiVersion}” and then each action has its own part of the template (for instance, [GetHttp(“Values”)]).

    When I call “https://…/odata/v1/Values” or “https://…/odata/v2/Values” I get always the response from the same controller.

    Even more, if I call “https://…/odata/v3/Values” I get a response, when actually I was waiting for an HTTP 404 Not Found since “V3” does not exists.

    Any idea on how can I get versioning work?

    Thank you in advance!

Feedback usabilla icon