Резервная API-версия .NET Core WebAPI в случае отсутствия дополнительной версии - PullRequest
0 голосов
/ 24 июня 2019

После многих попыток и прочтения статей я решил разместить здесь свой выпуск. То, что я хочу, заключается в следующем: я работаю над api-версиями приложения. Поддерживаемый формат версии .NET Core (Microsoft.AspNetCore.Mvc.Versioning пакет) - Major.Minor, и это то, что я хочу использовать в проекте, над которым я работаю. Я хочу иметь запасную версию на тот случай, если младшая версия не указана клиентом. Я использую ядро ​​.NET 2.2 и использую api-version, указанное в шапке. Соответствующая конфигурация управления версиями API выглядит следующим образом:

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

У меня есть следующие два контроллера для каждой версии: (контроллеры упрощены ради этого вопроса SO):

[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";  
    }  
}  

Если клиент указывает api-version=1.0, то используется ValueControllerV10. И, конечно, если клиент указывает api-version=1.1, тогда ValueControllerV11 используется, как и ожидалось.

А теперь приходит моя проблема. Если клиент указывает api-version=1 (так что только основная версия без вспомогательной версии), то используется ValueControllerV10. Это потому, что ApiVersion.Parse("1") равно ApiVersion.Parse("1.0"), если я не ошибаюсь. Но в этом случае я хочу вызвать последнюю версию данной основной версии, которая в моем примере равна 1.1.

Мои попытки:

Первый: Указание [ApiVersion("1")] в ValueControllerV11

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

не работает, ведет

AmbiguousMatchException: The request matched multiple endpoints

Чтобы решить эту проблему, я предложил второй подход:

Второй : с использованием пользовательского IActionConstraint. Для этого я следовал этим статьям:

Затем я создал следующий класс:

[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;
    }
}

И используется в ValueControllerV11:

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

Ну, это решает AmbiguousMatchException, но переопределяет поведение по умолчанию пакета Microsoft.AspNetCore.Mvc.Versioning, поэтому, если клиент использует api-version 1.1, он получает 404 Not Found обратно, что понятно в соответствии с реализацией HttpRequestPriority

Третий : Использование MapSpaFallbackRoute в Startup.cs, условно:

        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();

Это тоже не работает, никакого влияния. Имя MapSpaFallbackRoute дает мне также ощущение, что это не то, что мне нужно использовать ...

Итак, мой вопрос: как я могу ввести альтернативное поведение «использовать последние» для случая, когда младшая версия не указана в api-version? Заранее спасибо!

Ответы [ 2 ]

2 голосов
/ 29 июня 2019

Это изначально не поддерживается "из коробки".Плавающие версии, диапазоны и т. Д. Противоречат принципам управления версиями API.Версия API не предполагает и не может подразумевать обратную совместимость.Если вы не контролируете обе стороны в закрытой системе, допущение того, что клиент может обработать любое изменение договора, даже если вы добавите только одного нового участника, является ошибкой.В конечном счете, если клиент запрашивает 1 / 1,0, то это то, что он должен получить, или сервер должен сказать, что он не поддерживается.

Не считая моего мнения, некоторые люди все еще хотят такой тип поведения.Это не особенно просто, но вы должны быть в состоянии достичь своей цели, используя пользовательский IApiVersionRoutePolicy или пользовательский сопоставитель конечных точек - это зависит от стиля маршрутизации, который вы используете.

Если вывсе еще используя legacy маршрутизацию, это может быть самым простым, потому что вы просто создаете новую политику или расширяете существующую DefaultApiVersionRoutePolicy путем переопределения OnSingleMatch и регистрируете ее в своей службеконфигурации.Вы будете знать, что вы ищете этот сценарий, потому что входящая версия API не будет иметь вспомогательной версии.Вы правы, что 1 и 1.0 будут совпадать, но младшая версия не объединяется;следовательно, ApiVersion.MinorVersion будет null в этом сценарии.

Если вы используете Маршрутизация конечной точки , вам необходимо заменить ApiVersionMatcherPolicy .Следующее должно быть близко к тому, чего вы хотите достичь:

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;
    }
}

Тогда вам нужно будет обновить конфигурацию вашей службы следующим образом:

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

Если мы рассмотрим такой контроллер, как этот:

[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}";
}

Когда вы запросите api/values?api-version=2, вы будете соответствовать 2.2.

Я повторю, что это, как правило, не очень хорошая идея, поскольку клиенты должны иметь возможность полагатьсяна стабильных версиях.Использование status в версии может быть более целесообразным, если вы хотите предварительную версию API (например: 2.0-beta1).

Надеюсь, это поможет.

0 голосов
/ 03 июля 2019

Ну, кредиты за ответ на этот вопрос достаются @Chris Martinez, с другой стороны, я мог бы найти другой способ решения моей проблемы: Я специально создал расширение для RouteAttribute, реализуя 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;
    }
}

Где IActionContraint выглядит следующим образом:

    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];
    }
}

Затем я могу использовать ApiVersionAttribute и мой пользовательский RouteWithVersionAttribute вместе, как показано ниже:

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

Ура!

...