104

API versioning extension with ASP.NET Core OData 8

 2 years ago
source link: https://devblogs.microsoft.com/odata/api-versioning-extension-with-asp-net-core-odata-8/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

API versioning extension with ASP.NET Core OData 8

Sam Xu

July 9th, 2021

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:

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:

<?xml version="1.0" encoding="utf-8"?> <edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"> <edmx:DataServices> <Schema Namespace="ODataApiVersion.Models.v1" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityType Name="Customer"> <Key> <PropertyRef Name="Id" /> </Key> <Property Name="Name" Type="Edm.String" /> <Property Name="PhoneNumber" Type="Edm.String" /> <Property Name="Id" Type="Edm.Int32" Nullable="false" /> <Property Name="ApiVersion" Type="Edm.String" /> </EntityType> </Schema> <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityContainer Name="Container"> <EntitySet Name="Customers" EntityType="ODataApiVersion.Models.v1.Customer" /> </EntityContainer> </Schema> </edmx:DataServices> </edmx:Edmx>

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

<?xml version="1.0" encoding="utf-8"?> <edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"> <edmx:DataServices> <Schema Namespace="ODataApiVersion.Models.v2" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityType Name="Customer"> <Key> <PropertyRef Name="Id" /> </Key> <Property Name="FirstName" Type="Edm.String" /> <Property Name="LastName" Type="Edm.String" /> <Property Name="Email" Type="Edm.String" /> <Property Name="Id" Type="Edm.Int32" Nullable="false" /> <Property Name="ApiVersion" Type="Edm.String" /> </EntityType> </Schema> <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityContainer Name="Container"> <EntitySet Name="Customers" EntityType="ODataApiVersion.Models.v2.Customer" /> </EntityContainer> </Schema> </edmx:DataServices> </edmx:Edmx>

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": "[email protected]",
      "Id": 11,
      "ApiVersion": "v2.0"
    },
    {
      "FirstName": "KIO",
      "LastName": "XU",
      "Email": "[email protected]",
      "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": "[email protected]",
            "ApiVersion": "v2.0"
        },
        {
            "Email": "[email protected]",
            "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 [email protected]. Thanks.

I uploaded the whole project to this repository.

Sam Xu

Senior Software Engineer, OData, Microsoft Graph, Open API, Swagger

Follow


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK