API versioning extension with ASP.NET Core OData 8

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.

15 comments

Comments are closed. Login to edit/delete your existing comments

  • 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

      • 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 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.

          • Rodrigo Liberoff Vázquez

            Hi @Sam!

            Sorry for the late reply. I can confirm that the current version of the NuGet library is not understanding the [controller] and [action] tokens in the route attribute.

            Actually, the “Microsoft.AspNetCore.OData.Routing.Conventions.AttributeRoutingConvention” class returns the following warning: “The path template ‘odata/[controller]/$count’ on the action ‘All’ in controller ‘Customers’ is not a valid OData path template. Resource not found for the segment ‘[controller]’.”

            I got the 8.0.1 version of the code, and notice in the “AttributeRoutingConvention” class that perhaps the “ProcessAttributeModel” private method should do something as follows:

            ...
                       var templateValues = new Dictionary(StringComparer.OrdinalIgnoreCase)
            
                        {
                            { "action", actionModel.ActionName },
                            { "controller", controllerModel.ControllerName },
                        };
            
                        newRouteTemplate = AttributeRouteModel.ReplaceTokens(newRouteTemplate, templateValues);
            ...
            

            That would “translate” the ‘[action]’ and ‘[controller]’ tokens before calling the “CreateActionSelectorModel” private method which is the one reporting the warning.

  • 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!

  • ivan zinov

    Do you have an example that works without the EDM Model? and keeps in some way the odata query language?

  • Michael Xu

    Hi Sam,

    Very informative post!

    I built OData service with multi-tenant dynamic models, so that odata/tenant1/$metadata and odata/tenant2/$metadata present tenant specific models. Before OData 8, I created an extension (sample code below). You sample well serves my purpose, but I would still appreciate if you have any comment, or suggestion on different approach, samples, etc.

    Thanks,
    Michael

            
    public static IEndpointRouteBuilder CustomMapODataRoute(this IEndpointRouteBuilder endpoints,
                string routeName, string routePrefix, ODataSourceProvider provider)
            {
               //ODataSourcePrivider is model provider, similar to MyODataModelProvider
                endpoints.MapODataRoute(routeName, routePrefix, containerBuilder =>
                {
                    containerBuilder.AddService(ServiceLifetime.Scoped, serviceProvider =>
                    {
                        var serviceScope = serviceProvider.GetRequiredService();
                        var tenant = serviceScope.HttpRequest.GetTenant(); //GetTenant is my extension method which get tenant1 or tenant2 from query string
                        var model = provider.GetEdmModel(tenant);
    
                        return model;
                    });
    
    
                    containerBuilder.AddService(ServiceLifetime.Scoped, typeof(IEnumerable), sp =>
                    {
                        var routingConventions = ODataRoutingConventions.CreateDefault();
                        routingConventions.Insert(0, new MatchAllRoutingConvention());
                        return routingConventions.ToList().AsEnumerable();
                    });
                });
    
                endpoints.MapDynamicControllerRoute($"odata/{{{""tenant""}}}/{{{""dataSource""}}}");
    
                return endpoints;
            }
    
      • Michael Xu

        The OData API endpoint was designed to support multi-tenant to work with the web front application, each tenant has its own set of edm models. api obtains the tenant name from url, and ODataModelProvider takes tenant’s name as parameter to return tenant specific models.
        odata/tenant1/$metadata returns models’ metadata belongs to tenant1.

  • Serge Zoghbi

    Hello,

    Thank you very much for this post it really helped me.
    But I have a small question how can I use ODataPayloadValueConverter where exactly should I add it in my configuration.

        public class DateTimeValueNullifier : ODataPayloadValueConverter
        {
            public override object ConvertToPayloadValue(object value, IEdmTypeReference edmTypeReference)
            {
                if (value is DateTime)
                {
                    return null;
                }
                else
                {
                    return base.ConvertToPayloadValue(value, edmTypeReference);
                }
            }
            
            public override object ConvertFromPayloadValue(object value, IEdmTypeReference edmTypeReference)
            {
                if (edmTypeReference.IsDateTimeOffset() && value is string)
                {
                    return DateTimeOffset.Parse((string)value, CultureInfo.InvariantCulture);
                }
    
                return base.ConvertFromPayloadValue(value, edmTypeReference);
            }
        }

    My Configuration:

         services.AddControllers(options =>
                    {
                        options.EnableEndpointRouting = false;
                        var policy = new AuthorizationPolicyBuilder()
                            .RequireAuthenticatedUser()
                            .Build();
                        options.Filters.Add(new AuthorizeFilter(policy));
                    })
                    .SetCompatibilityVersion(CompatibilityVersion.Latest)
                    .AddNewtonsoftJson(opt =>
                    {
                        opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
                        opt.SerializerSettings.ContractResolver = new DefaultContractResolver();
                        opt.SerializerSettings.Formatting = Formatting.Indented;
                    }).AddOData(options =>
                    {
                        var oDataModelBuilder = services.BuildServiceProvider().GetRequiredService();
                        options.AddRouteComponents("odata", oDataModelBuilder.GetEdmModel());
    
                    });

    Regards,
    Thanks a lot