5

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!

mirind4
  • 1,423
  • 4
  • 21
  • 29

3 Answers3

4

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.

Chris Martinez
  • 3,185
  • 12
  • 28
  • thanks a lot for your detailed answer, I learned a lot from it! (and sorry for the late answer, had some really busy days lately...). Well, it seems it is the proper way to do it, but would you be so kind as to check my answer to this same question?I just posted it..I could namely figure out another way to solve my issue and I wonder whether it is also a correct solution or I could shoot myself in the foot with it. Thanks again a lot! – mirind4 Jul 03 '19 at 12:28
  • 1
    Has the OP moved to .NET Core 3.1 yet? I'd be interested to see the updated implementation of the above as I'm having real trouble getting our versioning to work in 3.1 – pr.lwd Sep 30 '20 at 07:50
  • Apologies for not knowing what _the OP_ is, but API Versioning is supported on .NET Core 3.1 For this particular scenario, the setup would be largely the same. With _Endpoint Routing_, you could probably use the matcher as shown above and just register it after API versioning. You would essentially mark an invalid candidate valid again; however, it's probably safest to just replace the registered implementation. – Chris Martinez Oct 01 '20 at 00:18
0

Well, the credits for answering the question goes to @Chris Martinez, on the other hand I could figure out another way to solve my issue: I have namely created an extension for RouteAttribute, implementing IActionConstraintFactory:

public class RouteWithVersionAttribute : RouteAttribute, IActionConstraintFactory
{
    private readonly IActionConstraint _constraint;

    public bool IsReusable => true;

    public RouteWithVersionAttribute(string template, params string[] apiVersions) : base(template)
    {
        Order = -10; //Minus value means that the api-version specific route to be processed before other routes
        _constraint = new ApiVersionHeaderConstraint(apiVersions);
    }

    public IActionConstraint CreateInstance(IServiceProvider services)
    {
        return _constraint;
    }
}

Where the IActionContraint looks like the following:

    public class ApiVersionHeaderConstraint : IActionConstraint
{
    private const bool AllowRouteToBeHit = true;
    private const bool NotAllowRouteToBeHit = false;

    private readonly string[] _allowedApiVersions;

    public ApiVersionHeaderConstraint(params string[] allowedApiVersions)
    {
        _allowedApiVersions = allowedApiVersions;
    }

    public int Order => 0;

    public bool Accept(ActionConstraintContext context)
    {
        var requestApiVersion = GetApiVersionFromRequest(context);

        if (_allowedApiVersions.Contains(requestApiVersion))
        {
            return AllowRouteToBeHit;
        }

        return NotAllowRouteToBeHit;
    }

    private static string GetApiVersionFromRequest(ActionConstraintContext context)
    {
        return context.RouteContext.HttpContext.Request.GetTypedHeaders().Headers[CollectApiVersion.HeaderKey];
    }
}

Then I can use the ApiVersionAttribute and my custom RouteWithVersionAttribute together, as follows:

[ApiVersion("1")]
[ApiVersion("1.1")]
[Route("collect", "1", "1.1")]
public class ValueControllerV11 : Controller
{
    [HttpRequestPriority]
    public String Collect()
    {
        return "Version 1.1";
    }
}

Cheers!

mirind4
  • 1,423
  • 4
  • 21
  • 29
  • It seems like this _could_ work. The biggest drawback to this approach would be that you have to apply this to all your controllers, which is likely undesirable. You _shouldn't_ have to relist the _allowed_ API versions because you've all ready declared them via `[ApiVersion]`. In your route constraint, you can access them via `context.CurrentCandidate.Action.GetApiVersionModel()`, which is calculated at application start. You can then get the currently requested API version via `context.RouteContext.HttpContext.GetRequestedApiVersion()`. – Chris Martinez Jul 03 '19 at 20:32
  • Now it's just a matter of comparing the requested API version to the implemented set. This _might_ be done with something like: `var max = model.ImplementedApiVersions.Where(v => v.MajorVersion == requestedVersion.MajorVersion).Max()` and then `context.CurrentCandidate.Action.MappingTo(max) != ApiVersioningMapping.None`. I should also mention that if you allow any type of implicit versioning, this will fall down because there is no value to get from the request pipeline. I hope that helps and gives you a few more ideas. – Chris Martinez Jul 03 '19 at 20:37
  • @ChrisMartinez Lovely, good points, I gonna process them! Thanks again for your detailed help, I wish the best! ;) – mirind4 Jul 04 '19 at 08:09
0

what about the CurrentImplementationApiVersionSelector option, when registering the service? see here: https://github.com/microsoft/aspnet-api-versioning/wiki/API-Version-Selector

The CurrentImplementationApiVersionSelector selects the maximum API version available which does not have a version status. If no match is found, it falls back to the configured DefaultApiVersion. For example, if the versions "1.0", "2.0", and "3.0-Alpha" are available, then "2.0" will be selected because it's the highest, implemented or released API version.

services.AddApiVersioning(
    options => options.ApiVersionSelector =
        new CurrentImplementationApiVersionSelector( options ) );
  • Welcome to stackoverflow. Short answers with an external link are considered low quality as the content of the external link can change. Best practice is to include the key details in your answer. – Simon.S.A. Oct 02 '19 at 20:12
  • 1
    I just noticed this, but thought I would reply. Although it is a question to question, it's a good one. The reason you cannot use **CurrentImplementationApiVersionSelector** here is because if you had `2.0`, `2.2`, and `3.0`, the desire is only to go to `2.2`, but the selector will choose `3.0`. You could also implement a custom **IApiVersionSelector**, but it will **not** be invoked - by design - when a client specifies an explicit version. There's no simple override to this behavior without changing some core routing bits. – Chris Martinez Oct 01 '20 at 00:13