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:
- http://localhost:5000/Customers?api-version=1.0 returns “Customers” for version 1.0
- http://localhost:5000/Customers?api-version=2.0 returns “Customers” for version 2.0
- http://localhost:5000/Customers?api-version=3.0 returns “Version 3.0 NotSupported exception“.
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:
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.
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.
<code>
My Configuration:
<code>
Regards,
Thanks a lot
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
<code>
@Michael Xu Thanks. Would you please share more detailed requirement?
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.
Dear Sam,
The creator of the ApiVersioning extension is lacking communication with OData team. It seems also that you can help to progress with this issue: https://github.com/dotnet/aspnet-api-versioning/issues/677. Can you take a look please?
Best Regards
Thanks for your information.
Do you have an example that works without the EDM Model? and keeps in some way the odata query language?
@ivan ainov Please check here: https://github.com/OData/AspNetCoreOData/tree/master/sample/ODataRoutingSample#non-edm-model
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...
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
I have another post mentioning attribute routing. You might be take a look https://devblogs.microsoft.com/odata/attribute-routing-in-asp-net-core-odata-8-0-rc/
If you still can’t make your project working, please share it with me using a github repo. I am very happy to review it.
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...
@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 Do you mind to create a PR for me? I’d love to review it and merge it into the repository.
https://github.com/OData/AspNetCoreOData/compare
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...