API versioning extension with ASP.NET Core OData 8

Sam Xu

Sam

Introduction

API versioning can help evolving our APIs without changing or breaking the existing API services. URL segment, request header, and query string are three ways to achieve API versioning in ASP.NET Core application.

ASP.NET Core OData 8, built upon ASP.NET Core, has the built-in API versioning functionality via route URL prefix template. For instance, the following code configures a version template in the route URL prefix to achieve URL based API versioning:

  services.AddControllers()
    .AddOData(opt => opt.AddRouteComponents("v{version}", edmModel));

Based on this configuration, it supports API versioning using URL segment as:

  • http://localhost:5000/v1.0/Customers
  • http://localhost:5000/v2.0/Customers

ASP.NET Core OData 8 doesn’t have the built-in API versioning based on query string and request header. However, it’s easy to extend the package to achieve these two API versionings. This post will create the extensions to build the query string API versioning with ASP.NET Core OData 8.x and share with you the ideas of how easy to extend ASP.NET Core OData 8. The same way also applies to the request header.

Let’s get started.

Scenarios

We want to build an API which can return the different version of Customers data based on api-version query string using the same request URL, for example:

Be noted, v1 and v2 use the same HTTP request path.

Prerequisites

Let us create an ASP.NET Core Application called “ODataApiVersion” using Visual Studio 2019. You can follow up any guide or refer to ASP.NET Core OData 8.0 Preview for .NET 5 to create this application.

We install the following nuget packages:

  • Microsoft.AspNetCore.OData -version 8.0.1
  • Microsoft.AspNetCore.Mvc.Versioning -version 5.0

CLR Model

Once the application is created, let’s create a folder named “Models” in the solution explorer. In this folder, let’s create the following three C# classes for our CLR model:

Namespace ODataApiVersion.Models
{
    public abstract class CustomerBase
    {
        public int Id { get; set; }
        public string ApiVersion { get; set; }
     }
}

Namespace ODataApiVersion.Models.v1
{
    public class Customer : CustomerBase
    {
        public string Name { get; set; }
        public string PhoneNumber { get; set; }
    }
}

Namespace ODataApiVersion.Models.v2
{
    public class Customer : CustomerBase
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public string Email { get; set; }
    }
}

Be noted: the two concrete classes have the same name “Customer” but in different namespace.

Edm Model provider

We need an Edm model provider to provide the Edm model based on the API version.

Let’s create the following interface and use it as a service in the dependency injection:

public interface IODataModelProvider
{
    IEdmModel GetEdmModel(string apiVersion);
}

We create a default implementation for the model provider interface as

public class MyODataModelProvider : IODataModelProvider
{
    private IDictionary<string, IEdmModel> _cached = new Dictionary<string, IEdmModel>();
    public IEdmModel GetEdmModel(string apiVersion)
    {
        if (_cached.TryGetValue(apiVersion, out IEdmModel model))
        {
            return model;
        }

        model = BuildEdmModel(apiVersion);
        _cached[apiVersion] = model;
        return model;
    }

    private static IEdmModel BuildEdmModel(string version)
    {
        switch (version)
        {
            case "1.0": return BuildV1Model();
            case "2.0": return BuildV2Model();
        }

        throw new NotSupportedException($"The input version '{version}' is not supported!");
    }

    private static IEdmModel BuildV1Model()
    {
        var builder = new ODataConventionModelBuilder();
        builder.EntitySet<Models.v1.Customer>("Customers");
        return builder.GetEdmModel();
    }

    private static IEdmModel BuildV2Model()
    {
        var builder = new ODataConventionModelBuilder();
        builder.EntitySet<Models.v2.Customer>("Customers");
        return builder.GetEdmModel();
    }
}

Be noted: v1 and v2 Edm model have the same entity set named “Customers“.

CustomersController

We need two controllers to handle the same request for different API versions. In the “Controllers” folder, add two controllers using the same name “CustomersController” but different namespace.

namespace ODataApiVersion.Controllers.v1
{
    [ApiVersion("1.0")]
    public class CustomersController : ODataController
    {
        private Customer[] customers = new Customer[]
        {
            // ...... Omit the codes, you can find them from the project
        };

        [EnableQuery]
        public IActionResult Get()
        {
            return Ok(customers);
        }

        [EnableQuery]
        public IActionResult Get(int key)
        {
            var customer = customers.FirstOrDefault(c => c.Id == key);
            if (customer == null)
            {
                return NotFound($"Cannot find customer with Id={key}.");
            }

            return Ok(customer);

         }
    }
}

namespace ODataApiVersion.Controllers.v2
{
    [ApiVersion("2.0")]
    public class CustomersController : ODataController
    {
        private Customer[] _customers = new Customer[]
        {
            // ...... Omit the codes, you can find them from the project
        };

        [EnableQuery]
        public IActionResult Get()
        {
            return Ok(customers);
        }

        [EnableQuery]
        public IActionResult Get(int key)
        {
            // ...... Omit the codes, you can find them from the project
         }
    }
}

Be noted: Each controller has [ApiVersionAttribute] decorated using different version string.

Construct the routing template

We need to build the routing template for the action in the controller, which is used to match the coming request. The built-in OData routing convention cannot meet this requirement. So, we have to build the routing template using an IApplicationModelProvider .

public class MyODataRoutingApplicationModelProvider : IApplicationModelProvider
{
    public int Order => 90;
    public void OnProvidersExecuted(ApplicationModelProviderContext context)
    {
        IEdmModel model = EdmCoreModel.Instance; // just for place holder
        string prefix = string.Empty;
        foreach (var controllerModel in context.Result.Controllers)
        {
            // CustomersController
            if (controllerModel.ControllerName == "Customers")
            {
                ProcessCustomersController(prefix, model, controllerModel);
                continue;
            }

            // MetadataController
            if (controllerModel.ControllerName == "Metadata")
            {
                ProcessMetadata(prefix, model, controllerModel);
                continue;
            }
        }
    }

    public void OnProvidersExecuting(ApplicationModelProviderContext context)
    {}

    private static void ProcessCustomersController(string prefix, IEdmModel model, ControllerModel controllerModel)
    {
        foreach (var actionModel in controllerModel.Actions)
        {
            // For simplicity, only check the parameter count
            if (actionModel.ActionName == "Get")
            {
                if (actionModel.Parameters.Count == 0)
                {
                    ODataPathTemplate path = new ODataPathTemplate(new EntitySetCustomersSegment());
                    actionModel.AddSelector("get", prefix, model, path);
                }
                else
                {
                   ODataPathTemplate path = new ODataPathTemplate(
                        new EntitySetCustomersSegment(),
                        new EntitySetWithKeySegment());
                   actionModel.AddSelector("get", prefix, model, path);
                }
            }
        }
    }

    private static void ProcessMetadata(string prefix, IEdmModel model, ControllerModel controllerModel)
    {
        // ...... Omit the codes, you can find them from the project
    }
}

Be noted: the above codes handle the actions on:

  • v1.CustomersController
  • v2.CustomersController
  • MetadataController

Besides, EntitySetCustomersSegment has the following codes:

public class EntitySetCustomersSegment : ODataSegmentTemplate
{
    public override IEnumerable<string> GetTemplates(ODataRouteOptions options)
    {
        yield return "/Customers";
    }

    public override bool TryTranslate(ODataTemplateTranslateContext context)
    {
        // Support case-insenstivie
        var edmEntitySet = context.Model.EntityContainer.EntitySets()
            .FirstOrDefault(e => string.Equals("Customers", e.Name, StringComparison.OrdinalIgnoreCase));

        if (edmEntitySet != null)
        {
            EntitySetSegment segment = new EntitySetSegment(edmEntitySet);
            context.Segments.Add(segment);
            return true;
        }

        return false;
    }
}

And EntitySetWithKeySegment has the following codes:

public class EntitySetWithKeySegment : ODataSegmentTemplate
{
    public override IEnumerable<string> GetTemplates(ODataRouteOptions options)
    {
        yield return "/{key}";
     // yield return "({key})"; enable it if you want to support key in parenthesis
    }

    public override bool TryTranslate(ODataTemplateTranslateContext context)
    {
        // ...... Omit the codes, you can find them from the project
    }
}

Routing matcher policy

We need a routing matcher policy to select the best endpoint. Here’s our implementation

internal class MyODataRoutingMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
    private readonly IODataTemplateTranslator _translator;
    private readonly IODataModelProvider _provider;
    private readonly ODataOptions _options;

    public MyODataRoutingMatcherPolicy(IODataTemplateTranslator translator,
        IODataModelProvider provider,
        IOptions<ODataOptions> options)
    {
        _translator = translator;
        _provider = provider;
        _options = options.Value;
    }

    public override int Order => 900 - 1; // minus 1 to make sure it's running before built-in OData matcher policy

    public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
    {
        return endpoints.Any(e => e.Metadata.OfType<ODataRoutingMetadata>().FirstOrDefault() != null);
    }

    public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
    {
        // ...... omit some checking codes

        for (var i = 0; i < candidates.Count; i++)
        {
            ref CandidateState candidate = ref candidates[i];
            if (!candidates.IsValidCandidate(i))
            {
                continue;
            }

            IODataRoutingMetadata metadata = candidate.Endpoint.Metadata.OfType<IODataRoutingMetadata>().FirstOrDefault();
            if (metadata == null)
            {
                continue;
            }

            // Get api-version query from HttpRequest?
            QueryStringApiVersionReader reader = new QueryStringApiVersionReader("api-version");
            string apiVersionStr = reader.Read(httpContext.Request);
            if (apiVersionStr == null)
            {
                candidates.SetValidity(i, false);
                continue;
            }

            ApiVersion apiVersion = ApiVersion.Parse(apiVersionStr);
            IEdmModel model = GetEdmModel(apiVersion);
            if (model == null)
            {
                candidates.SetValidity(i, false);
                continue;
            }

            if (!IsApiVersionMatch(candidate.Endpoint.Metadata, apiVersion))
            {
                candidates.SetValidity(i, false);
                continue;
            }

            ODataTemplateTranslateContext translatorContext
                = new ODataTemplateTranslateContext(httpContext, candidate.Endpoint, candidate.Values, model);

            try
            {
                ODataPath odataPath = _translator.Translate(metadata.Template, translatorContext);
                if (odataPath != null)
                {
                    odataFeature.RoutePrefix = metadata.Prefix;
                    odataFeature.Model = model;
                    odataFeature.Path = odataPath;

                    ODataOptions options = new ODataOptions();
                    UpdateQuerySetting(options);
                    options.AddRouteComponents(model);
                    odataFeature.Services = options.GetRouteServices(string.Empty);

                    MergeRouteValues(translatorContext.UpdatedValues, candidate.Values);
                }
                else
                {
                    candidates.SetValidity(i, false);
                }
            }
            catch
            {
                candidates.SetValidity(i, false);
            }
        }

        return Task.CompletedTask;
    }

    private void UpdateQuerySetting(ODataOptions options)
    {
        // ...... omit the setting copy codes
    }

    private static void MergeRouteValues(RouteValueDictionary updates, RouteValueDictionary source)
    {
        foreach (var data in updates)
        {
            source[data.Key] = data.Value;
        }
    }

    private IEdmModel GetEdmModel(ApiVersion apiVersion)
    {
        return _provider.GetEdmModel(apiVersion.ToString());
    }

    private static bool IsApiVersionMatch(EndpointMetadataCollection metadata, ApiVersion apiVersion)
    {
        var apiVersions = metadata.OfType<ApiVersionAttribute>().ToArray();
        if (apiVersions.Length == 0)
        {
            // If no [ApiVersion] on the controller,
            // Let's simply return true, it means it can work the input version or any version.
            return true;
        }

        foreach (var item in apiVersions)
        {
            if (item.Versions.Contains(apiVersion))
            {
                return true;
            }
        }

        return false;
    }
}

Be noted: The order value is “900 – 1” to make sure this policy is applied before the built-in OData routing match policy.

Config the services

Now, let’s configure the above extensions as services into the service collection in the Startup class as below:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers().AddOData();
    services.TryAddSingleton<IODataModelProvider, MyODataModelProvider>();
    services.TryAddEnumerable(
        ServiceDescriptor.Transient<IApplicationModelProvider, MyODataRoutingApplicationModelProvider>());
    services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, MyODataRoutingMatcherPolicy>());
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ......
    app.UseODataRouteDebug();

    app.UseRouting();
    // ......
}

Routing Debug

You may noticed we call app.UseODataRouteDebug() in Configure(…) method. This function enables /$odata middleware. Run the application and send http://localhost:5000/$odata in any internet browser, you can get the following html page:

Image dollarOData

This HTML page gives you a whole routing template picture. You can see we have the same routing templates for different actions. For example, Get(int key) action in v1.CustomersController and v2.CustomersController have the same routing template as ~/Customers/{key}.

Run and test the functionalities

Now, we can run and test the API versioning functionalities.

Query metadata

As mentioned, we have the codes in MyODataRoutingApplicationModelProvider to process the MetadataController, it supports the metadata versioning.

Send Http request http://localhost:5000/$metadata?api-version=1.0, you can get:

It is also working with http://localhost:5000/$metadata?api-version=2.0 Http request, it will return the following metadata.

If you send Http request using unsupported version, for example, http://localhost:5000/$metadata?api-version=3.0, you will get

System.NotSupportedException: The input version 3.0 is not supported!

Query Customers

We can query the entity set Customers using a different API version.

Send Http request http://localhost:5000/Customers?api-version=1.0, you can get the following JSON response:

{
  "@odata.context": "http://localhost:5000/$metadata#Customers",
  "value": [
    {
      "Name": "Sam",
      "PhoneNumber": "111-222-3333",
      "Id": 1,
      "ApiVersion": "v1.0"
    },
    {
      "Name": "Peter",
      "PhoneNumber": "456-ABC-8888",
      "Id": 2,
      "ApiVersion": "v1.0"
    }
  ]
}

Send http://localhost:5000/Customers?api-version=2.0 request, you can get the following JSON response:

{
  "@odata.context": "http://localhost:5000/$metadata#Customers",
  "value": [
    {
      "FirstName": "YXS",
      "LastName": "WU",
      "Email": "yxswu@abc.com",
      "Id": 11,
      "ApiVersion": "v2.0"
    },
    {
      "FirstName": "KIO",
      "LastName": "XU",
      "Email": "kioxu@efg.com",
      "Id": 12,
      "ApiVersion": "v2.0"
    }
  ]
}

Send http://localhost:5000/ShipmentContracts?api-version=3.0 request, you will get the same error:

System.NotSupportedException: The input version 3.0 is not supported!

Since we have created the single entity route template, the following URLs also work as expected.

  • http://localhost:5000/Customers/2?api-version=1.0
  • http://localhost:5000/Customers/12?api-version=2.0

Using OData query option

You can use the config methods on ODataOptions to enable OData query option. For instance, you can call “Select()” to enable $select OData query option.

services.AddControllers().AddOData(opt => opt.Select());

Now, we have the $select functionality enabled.

Send http://localhost:5000/Customers?api-version=2.0&$select=Email,ApiVersion

You can get the following response payload:

{
    "@odata.context": "http://localhost:5000/$metadata#Customers(Email,ApiVersion)",
    "value": [
        {
            "Email": "yxswu@abc.com",
            "ApiVersion": "v2.0"
        },
        {
            "Email": "kioxu@efg.com",
            "ApiVersion": "v2.0"
        }
    ]
}

Please try other config methods in ODataOptions to enable more OData query option functionalities.

Summary

This post went throw the steps on how to enable API query string versioning with ASP.NET Core OData 8. Hope the ideas and implementations in this post can help you understand how to extend the functionality for ASP.NET Core OData. Please do not hesitate to leave your comments below or let me know your thoughts through saxu@microsoft.com. Thanks.

I uploaded the whole project to this repository.

5 comments

Leave a comment

  • Avatar
    Rodrigo Liberoff Vázquez

    Hello!

    Nice Post!

    I was looking for something like this. One question, how does ” services.AddControllers().AddOData(opt => opt.AddRouteComponents(“v{version}”, edmModel));” works with Attribute Routing?

    I’ve been unable to make it work.

    How should the attribute routing in the Controller class be to make that configuration work?

    Tahnk you.

    Kind regards,

    Rodrigo

      • Avatar
        Rodrigo Liberoff Vázquez

        Hi @Sam!

        Thank you for your answer.

        Yes, I read that post. However, what I’m trying to do is having a behaviour like we currently have with the API Versioning for REST endpoints in ASP.NET Core. For instance, I have the controller decorated as with [Route(@”odata/v{version:apiVersion}”)], and expect the .NET Core routing to replace the {version:apiVersion} with the version number, for example from the namespace of the controller.

        Also, it does not recognizes the “[controller]” token in the route attribute, for example: “[Route(@”odata/v{version:apiVersion}/[controller]”)]”

        In other words, I would like to provide developers with OData with the same experience they currently have with the API Versioning library.

        Is there any plan for that?

        Thank you very much.

        Kind regards,

        Rodrigo

        • Sam Xu
          Sam XuMicrosoft employee

          @Rodrigo In the introduction, I share the example how to set the URL based versioning using ASP.NET Core OData. Please take a look. For the token [controller] and [action], I remember when goes into OData routing convention, the token is replaced by ASP.NET Core using the controller name and action name. Please try and let me know the result.

  • Avatar
    Ian Yates

    Can you clarify if some of this code could be called by multiple threads at once? In particular, there’s a dictionary being used as a cache for the edm models, and it’s lazily populated. That would fall down if this method is called by multiple requests in parallel (as I assume it could be).
    Just a note since someone using this as a starting point for supporting api versions may well take that small part of this verbatim, not having the contextual awareness of regular Dictionary and the slim but real chance of two threads mutating it at once and causing corruption of its internal state.

    Otherwise, cool tech. Thanks for the detailed post!