Search code examples
asp.net-mvcasp.net-web-api.net-coreapi-versioning

.NET Core WebAPI fall-back API-version in case of missing minor version


After many tries and read articles I decided to place my issue here. What I want is the following: I am working on api-versioning of an application. A supported version format by .NET Core (Microsoft.AspNetCore.Mvc.Versioning package) is Major.Minor, and this is what I want to use in the project I work on. What I want is to have is a fall-back version in case when the minor version is not specified by the client. I am using .NET core 2.2, and using api-version specified in the header. The corresponding API versioning config looks like this:

    services.AddApiVersioning(options => { 
        options.ReportApiVersions = true;
        options.ApiVersionReader = new HeaderApiVersionReader("api-version");
        options.ErrorResponses = new ApiVersioningErrorResponseProvider();
    });

I have the following two controllers for each version: (the controllers are simplified for the sake of this SO question):

[ApiVersion("1.0")]  
[Route("api/[controller]")]  
public class ValueControllerV10 : Controller  
{  
    [HttpGet(Name = "collect")]  
    public String Collect()  
    {  
        return "Version 1.0";  
    }  
} 


[ApiVersion("1.1")]  
[Route("api/[controller]")]  
public class ValueControllerV11 : Controller  
{  
    [HttpGet(Name = "collect")]  
    public String Collect()  
    {  
        return "Version 1.1";  
    }  
}  

If the client specifies api-version=1.0 then the ValueControllerV10 is used. And of course if the client specifies api-version=1.1, then the ValueControllerV11 is used, as expected.

And now comes my problem. If the client specifies api-version=1 (so only the major version without the minor version), then the ValueControllerV10 is used. It is because ApiVersion.Parse("1") is equal to ApiVersion.Parse("1.0"), if i am not mistaken. But what I want in this case is to invoke the latest version of the given major version, which is 1.1 in my example.

My attempts:

First: Specifying [ApiVersion("1")] at ValueControllerV11

    [ApiVersion("1")]  
    [ApiVersion("1.1")]  
    [Route("api/[controller]")]  
    public class ValueControllerV11 : Controller  
    {  
        [HttpGet(Name = "collect")]  
        public String Collect()  
        {  
            return "Version 1.1";  
        }  
    }  

It does not work, it leads

AmbiguousMatchException: The request matched multiple endpoints

To solve this, I have came up with the second approach:

Second: using custom IActionConstraint. For this I followed these articles:

I have then created the following class:

[AttributeUsage(AttributeTargets.Method)]
public class HttpRequestPriority : Attribute, IActionConstraint
{
    public int Order
    {
        get
        {
            return 0;
        }
    }

    public bool Accept(ActionConstraintContext context)
    {
        var requestedApiVersion = context.RouteContext.HttpContext.GetRequestedApiVersion();

        if (requestedApiVersion.MajorVersion.Equals(1) && !requestedApiVersion.MinorVersion.HasValue)
        {
            return true;
        }

        return false;
    }
}

And used at ValueControllerV11:

[ApiVersion("1")]  
[ApiVersion("1.1")]  
[Route("api/[controller]")]  
public class ValueControllerV11 : Controller  
{  
    [HttpGet(Name = "collect")]
    [HttpRequestPriority]  
    public String Collect()  
    {  
        return "Version 1.1";  
    }  
}

Well, it solves the AmbiguousMatchException, but overrides the default behaviour of Microsoft.AspNetCore.Mvc.Versioning package so if the client uses api-version 1.1, then she get a 404 Not Found back, which is understandable according to the implementation of HttpRequestPriority

Third: Using MapSpaFallbackRoute in Startup.cs, conditionally:

        app.MapWhen(x => x.GetRequestedApiVersion().Equals("1") && x.GetRequestedApiVersion().MinorVersion == null, builder =>
        {
            builder.UseMvc(routes =>
            {
                routes.MapSpaFallbackRoute(
                    name: "spa-fallback",
                    defaults: new {controller = nameof(ValueControllerV11), action = "Collect"});
            });
        });

        app.UseMvc();

It does not work either, no any impact. The name MapSpaFallbackRoute gives me also a feeling that it is not what I need to use...

So my question is: How can I introduce a fallback 'use latest' behaviour for the case when the minor version is not specified in api-version? Thanks in advance!


Solution

  • This is intrinsically not supported out-of-the-box. Floating versions, ranges, and so on are contrary to the principles of API Versioning. An API version does not, and cannot, imply any backward compatibility. Unless you control both sides in a closed system, assuming that a client can handle any contract change, even if you only add one new member, is a fallacy. Ultimately, if a client asks for 1/1.0 then that's what they should get or the server should say it's not supported.

    My opinion aside, some people still want this type of behavior. It's not particularly straight forward, but you should be able to achieve your goal using a custom IApiVersionRoutePolicy or custom endpoint matcher - it depends on the style of routing you're using.

    If you still using the legacy routing, this may be the easiest because you just create a new policy or extend the existing DefaultApiVersionRoutePolicy by overriding OnSingleMatch and register it in your service configuration. You'll know it's the scenario you're looking for because the incoming API version will not have the minor version. You are correct that 1 and 1.0 will equate as the same, but the minor version is not coalesced; therefore, ApiVersion.MinorVersion will be null in this scenario.

    If you're using Endpoint Routing, you'll need to replace the ApiVersionMatcherPolicy. The following should be close to what you want to achieve:

    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.Abstractions;
    using Microsoft.AspNetCore.Mvc.Routing;
    using Microsoft.AspNetCore.Mvc.Versioning;
    using Microsoft.AspNetCore.Routing;
    using Microsoft.AspNetCore.Routing.Matching;
    using Microsoft.Extensions.Logging;
    using Microsoft.Extensions.Options;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using static Microsoft.AspNetCore.Mvc.Versioning.ApiVersionMapping;
    
    public sealed class MinorApiVersionMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
    {
        public MinorApiVersionMatcherPolicy(
            IOptions<ApiVersioningOptions> options,
            IReportApiVersions reportApiVersions,
            ILoggerFactory loggerFactory )
        {
            DefaultMatcherPolicy = new ApiVersionMatcherPolicy(
                options, 
                reportApiVersions, 
                loggerFactory );
            Order = DefaultMatcherPolicy.Order;
        }
    
        private ApiVersionMatcherPolicy DefaultMatcherPolicy { get; }
    
        public override int Order { get; }
    
        public bool AppliesToEndpoints( IReadOnlyList<Endpoint> endpoints ) =>
            DefaultMatcherPolicy.AppliesToEndpoints( endpoints );
    
        public async Task ApplyAsync(
            HttpContext httpContext,
            EndpointSelectorContext context,
            CandidateSet candidates )
        {
            var requestedApiVersion = httpContext.GetRequestedApiVersion();
            var highestApiVersion = default( ApiVersion );
            var explicitIndex = -1;
            var implicitIndex = -1;
    
            // evaluate the default policy
            await DefaultMatcherPolicy.ApplyAsync( httpContext, context, candidates );
    
            if ( requestedApiVersion.MinorVersion.HasValue )
            {
                // we're done because a minor version was specified
                return;
            }
    
            var majorVersion = requestedApiVersion.MajorVersion;
    
            for ( var i = 0; i < candidates.Count; i++ )
            {
                // make all candidates invalid by default
                candidates.SetValidity( i, false );
    
                var candidate = candidates[i];
                var action = candidate.Endpoint.Metadata?.GetMetadata<ActionDescriptor>();
    
                if ( action == null )
                {
                    continue;
                }
    
                var model = action.GetApiVersionModel( Explicit | Implicit );
                var maxApiVersion = model.DeclaredApiVersions
                                            .Where( v => v.MajorVersion == majorVersion )
                                            .Max();
    
                // remember the candidate with the next highest api version
                if ( highestApiVersion == null || maxApiVersion >= highestApiVersion )
                {
                    highestApiVersion = maxApiVersion;
    
                    switch ( action.MappingTo( maxApiVersion ) )
                    {
                        case Explicit:
                            explicitIndex = i;
                            break;
                        case Implicit:
                            implicitIndex = i;
                            break;
                    }
                }
            }
    
            if ( explicitIndex < 0 && ( explicitIndex = implicitIndex ) < 0 )
            {
                return;
            }
    
            var feature = httpContext.Features.Get<IApiVersioningFeature>();
    
            // if there's a match:
            //
            // 1. make the candidate valid
            // 2. clear any existing endpoint (ex: 400 response)
            // 3. set the requested api version to the resolved value
            candidates.SetValidity( explicitIndex, true );
            context.Endpoint = null;
            feature.RequestedApiVersion = highestApiVersion;
        }
    }
    

    Then you'll need to update you service configuration like this:

    // IMPORTANT: must be configured after AddApiVersioning
    services.Remove( services.Single( s => s.ImplementationType == typeof( ApiVersionMatcherPolicy ) ) );
    services.TryAddEnumerable( ServiceDescriptor.Singleton<MatcherPolicy, MinorApiVersionMatcherPolicy>() );
    

    If we consider a controller like this:

    [ApiController]
    [ApiVersion( "2.0" )]
    [ApiVersion( "2.1" )]
    [ApiVersion( "2.2" )]
    [Route( "api/values" )]
    public class Values2Controller : ControllerBase
    {
        [HttpGet]
        public string Get( ApiVersion apiVersion ) =>
            $"Controller = {GetType().Name}\nVersion = {apiVersion}";
    
        [HttpGet]
        [MapToApiVersion( "2.1" )]
        public string Get2_1( ApiVersion apiVersion ) =>
            $"Controller = {GetType().Name}\nVersion = {apiVersion}";
    
        [HttpGet]
        [MapToApiVersion( "2.2" )]
        public string Get2_2( ApiVersion apiVersion ) =>
            $"Controller = {GetType().Name}\nVersion = {apiVersion}";
    }
    

    When you request api/values?api-version=2, you'll match 2.2.

    I'll reiterate that this is generally not a good idea as clients should be able to rely on stable versions. Using the status in the version may be more appropriate if you want pre-release APIs (ex: 2.0-beta1).

    I hope that helps.