Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Unable to customise version selection unless requested version matches api version so unable to ignore version status when comparing #1062

Unanswered
tiddlypip asked this question in Q&A
Discussion options

In version 5.0.0 we were able to use custom selectors and policies to specify how the version is selected so that when an api moved from preview to release calls would continue to be handled i.e. using a custom poiicy, requesting 2020-01-01-preview would match to version 2020-01-01. However it looks like the behaviours changed to allow the request to be rejected with a 4xx error instead of an exception later on in the process so ideally it would be good to have a method to override the feature check that uses the jump table.
A use case would be when swapping over slots so versions might change
2022-01-01 => 2022-01-01-obsolete
2023-01-01-preview => 2023-01-01
Requests using the preview api now fail despite the custom policy supporting the status change

You must be logged in to vote

Replies: 3 comments · 6 replies

Comment options

I have a couple of follow-up questions.

Question 1

...request to be rejected with a 4xx error instead of an exception...

In which scenario would an exception be thrown? There were already very few cases (ex: AmbiguousApiVersionException), but virtually all of them were handled. The only one that comes to mind that was unhandled was AmbiguousMatchException for duplicate routes. That is a platform-level exception that can still occur.

Great care is taken to avoid exceptions. Very few things are exceptional. An Exception doesn't translate over HTTP. Refactoring occurred in 6.0 that remove the last of the exceptions (that I can think of) such as malformed or ambiguous API versions. These would have already been handled, but now the responses do no rely on an exception being thrown. It would be good to know what scenario this happens(ed) in.

Question 2

...custom selectors and policies...

Do you have the world's simplest setup or example of this? This appears to imply you used a custom IEndpointMatcherPolicy? This would help provide a better sense of what you had and how you expect it to work. Getting down to the jump table was an unfortunate requirement to address properly handling 406 and 415. If you really got down into the routing details, I see how that might have caused changes.

I'm not sure I agree with implicitly changing versions in a request. Versions should be explicit from the client and exactly match to the server. That's the only way to reason about things deterministically. If one or more preview versions happens to be the same as the official release, why not just define that the real service supports both versions? The client doesn't know how you've mapped things on the server.

A trivial example:

[ApiVersion("2023-01-01")]
[ApiVersion("2023-01-01-preview")]
[Route("my/api")]
public class MyApiController : ControllerBase
{
}

Requesting ?api-version=2023-01-01-preview or ?api-version=2023-01-01 go to the same place. No need to muck around with routing. That can be true going the other way too.

If you really want to remap things, it can also be done without having to resort to changing the routing. Since your app is aware of the versions it does and doesn't support, you can use the world's simplest middleware:

internal sealed class ApiStatusMapper
    : Dictionary<ApiVersion, Dictionary<String, String>>
{
    public ApiStatusMapper()
    {
        var comparer = StringCompare.OrdinalIgnoreCase;

        this[new(new DateOnly(2022, 01, 01))] = new(comparer)
        {
            [string.Empty] = "obsolete",
        };

        this[new(new DateOnly(2023, 01, 01))] = new(comparer)
        {
            ["preview"] = string.Empty,
        };
    }
}

builder.Services.AddSingleton<ApiStatusMapper>();

var app = builder.Build();

app.Use((context, next) => {
    var feature = context.ApiVersioningFeature();

    if ( feature.RawRequestedApiVersions.Count == 1 &&
         feature.RequestedApiVersion is {} version )
    {
        var mapper = context.RequestServices.GetRequiredService<ApiStatusMapper>();

        if ( mapper.TryGetValue( version, out var statuses ) &&
             statuses.TryGetValue( version.Status ?? string.Empty, out var status ) )
        {
            feature.RequestedApiVersion = new( version.GroupVersion.Value, status );
        }
    }

    return next(context);
});

As long as it's registered fairly early in the pipeline, that should allow:

  • 2022-01-01 => 2022-01-01-obsolete
  • 2023-01-01-preview => 2023-01-01

without having to mess around with routing.

You must be logged in to vote
1 reply
@tiddlypip
Comment options

  1. Just to clarify, the cases where excetions occurred in v5 are now returned as 4xx responses which is a big improvement, I wasn't saying that I was getting any exceptions in version 6.x so apologies for any confusion there.
  2. We've followed your suggestion here: https://stackoverflow.com/questions/56738937/net-core-webapi-fall-back-api-version-in-case-of-missing-minor-version which we used to handle the status. I like the idea of adding both versions although there might be reservations about developers managing this properly, adding a new version when released might make mistakes more likely than updating the version constant and would also mean that it appears in redoc/swagger documentation as preview/obsolete/current so could be confusing. I'll have a play with adding middleware in the new year, thankyou for the suggestion and the effort in knocking up a poc.
Comment options

@commonsensesoftware Might be helpful to explain why we want to do this...

When we introduce a new preview version of an API, e.g. 2024-01-01-preview, we invite customers to use it. At some point later, the version is upgraded to stable and we remove the -preview tag. We want any customers that opted into the preview version to continue to be able to use it. We can advise them that the -preview tag is no longer needed but it won't suddenly break for them and we don't need to carefully time our release with any action customers need to take.

We are a large company with a lot of products and microservices and so we maintain common libraries for them. These include a MatcherPolicy that handled this and could do it without specific knowledge of services' version models. I think the suggestion to use middleware above would require each service to supply their own mappings (as versions will vary between services), which is a fair bit of work to introduce and maintain. Is there no way to get it working as it worked before? edit: I can put together a minimal repro if it helps

You must be logged in to vote
5 replies
@stevendarby
Comment options

workaround found: using similar middleware to above but using the IApiVersionDescriptionProvider to check if a requested -preview version is known. If not, but there's one that matches on all but status and has a null/empty status, we adjust the requested version to that.

@commonsensesoftware
Comment options

@stevendarby , fundamentally API versions are discrete and immutable. If they're not, then things are unpredictable and nondeterministic. There's absolutely nothing wrong with continuing to support a -preview version; especially if it is exactly the same as the released version. What I can't quite understand is why you would want to get down into the bowels of routing and a custom MatcherPolicy for this (unless there are other reasons). Why not just map both versions to the same place?

[ApiVersion("2024-01-01")]
[ApiVersion("2024-01-01-preview")]
[Route("my/api")]
public class MyApiController : ControllerBase
{
}

This is a trivial example. You don't have to use attributes. You could use conventions. You could also use custom attributes that are more tailored to your needs; for example, [OurApiVersion("2024-01-01", IncludePreview = true)]. This would emit 2024-01-01 and 2024-01-01-preview.

Using the IApiVersionDescriptionProvider can work, but only you know if it's the right choice. The results are collated across all APIs and versions. If you're versioning scheme is symmetrical, then I would expect it to work. On the other hand, if it's asymmetrical, then it's possible that one API has both 2024-01-01 and 2024-01-01-preview, while another has only one of them. This is specific to your implementation.

Consideration should be given to other implications of changing what the client requested. I presume at point, you probably do shutdown the preview version for good (but maybe not). One way you'd gauge if that is safe to do is looking at telemetry of requested API versions. If you're manipulating the value, the behavior becomes brittle. Depending on when and where you record the value, it may be unexpected because some other part of the pipeline changed it (and you didn't notice or realize it).

I'm here to help you achieve your goals, but for this scenario, I recommend staying out of custom routing land.

@stevendarby
Comment options

Thanks @commonsensesoftware. I’m hesitant to add additional preview attributes because of the extra versions that’ll appear in swashbuckle/swagger and get reported in supported/deprecated version response headers. We want to allow continued use of “-preview” without advertising it as such, if that makes sense.

I think the workaround will work as our versioning is always symmetrical. We have up 3 sets of controllers in a given service (deprecated, stable, preview) which are reversioned and shifted along at fairly fixed intervals that are communicated to customers.

@xavierjohn
Comment options

Already answered here https://stackoverflow.com/a/66788306

@commonsensesoftware
Comment options

@stevendarby I had a suspicion you might say that. Controlling and filtering the reported metadata is vastly easier than changing routing. There are multiple approaches and things you can do, but under the assumption that you don't want to advertise any information about previews anywhere, including OpenAPI/Swagger, but you still want things to be routed, you only need to change a few things:

  1. IReportApiVersions
  2. IApiVersionDescriptionProvider
  3. IApiVersionDescriptionProviderFactory (for completeness)
    a. This is required if you use DescribeApiVersions
    b. This covers the edge case with Minimal APIs, which are registered outside of DI
    c. If you use IApiVersionDescriptionProvider directly and without Minimal APIs, this is unnecessary
    d. I hope/plan to simplify this requirement in the future

IReportApiVersions

This will prevent any API version with a status from being reported by any API.

using Asp.Versioning;

internal sealed class NoPreviewApiVersionReporter : IReportApiVersions
{
    private readonly DefaultApiVersionReporter reporter;

    public NoPreviewApiVersionReporter( ISunsetPolicyManager sunsetPolicyManager ) =>
        reporter = new( sunsetPolicyManager );

    public ApiVersionMapping Mapping => reporter.Mapping;

    public void Report( HttpResponse response, ApiVersionModel apiVersionModel )
    {
        var filtered = new ApiVersionModel(
            WithoutPreviews( apiVersionModel.DeclaredApiVersions ),
            WithoutPreviews( apiVersionModel.SupportedApiVersions ),
            WithoutPreviews( apiVersionModel.DeprecatedApiVersions ),
            Enumerable.Empty<ApiVersion>(),
            Enumerable.Empty<ApiVersion>() );

        reporter.Report( response, filtered );
    }

    private static IEnumerable<ApiVersion> WithoutPreviews( IEnumerable<ApiVersion> apiVersions )
    {
        foreach ( var apiVersion in apiVersions )
        {
            if ( string.IsNullOrEmpty( apiVersion.Status ) )
            {
                yield return apiVersion;
            }
        }
    }
}

IApiVersionDescriptionProvider

This will remove the description for any API version with a status. This will prevent an OpenAPI document from being
created by Swashbuckle for those API versions.

using Asp.Versioning;
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;

internal sealed class NoPreviewApiDescriptionProvider : DefaultApiVersionDescriptionProvider
{
    public NoPreviewApiDescriptionProvider(
        IEnumerable<IApiVersionMetadataCollationProvider> providers,
        ISunsetPolicyManager sunsetPolicyManager,
        IOptions<ApiExplorerOptions> apiExplorerOptions )
        : base( providers, sunsetPolicyManager, apiExplorerOptions ) { }

    protected override IReadOnlyList<ApiVersionDescription> Describe( IReadOnlyList<ApiVersionMetadata> metadata )
    {
        var results = base.Describe( metadata );
        var filtered = new List<ApiVersionDescription>( capacity: results.Count );

        for ( var i = 0; i < results.Count; i++ )
        {
            var result = results[i];

            if ( string.IsNullOrEmpty( result.ApiVersion.Status ) )
            {
                filtered.Add( result );
            }
        }

        return filtered;
    }
}

IApiVersionDescriptionProviderFactory (Optional)

This is used to create an IApiVersionDescriptionProvider for DescribeApiVersions. Minimal APIs create an EndpointDataSource outside of DI and it cannot be evaluated early enough for Swashbuckle if this isn't in place. You really only need this for completeness. If you aren't using Minimal APIs, then using IApiVersionDescriptionProvider from DI directly (as in earlier library versions) will work without this.

using Asp.Versioning;
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;

internal sealed class NoPreviewApiVersionDescriptionProviderFactory : IApiVersionDescriptionProviderFactory
{
    private readonly ISunsetPolicyManager sunsetPolicyManager;
    private readonly IApiVersionMetadataCollationProvider[] providers;
    private readonly IOptions<ApiExplorerOptions> options;
    private readonly Func<IEnumerable<IApiVersionMetadataCollationProvider>,
                          ISunsetPolicyManager,
                          IOptions<ApiExplorerOptions>,
                          IApiVersionDescriptionProvider> activator;

    public NoPreviewApiVersionDescriptionProviderFactory(
        Func<IEnumerable<IApiVersionMetadataCollationProvider>,
             ISunsetPolicyManager,
             IOptions<ApiExplorerOptions>,
             IApiVersionDescriptionProvider> activator,
        ISunsetPolicyManager sunsetPolicyManager,
        IEnumerable<IApiVersionMetadataCollationProvider> providers,
        IOptions<ApiExplorerOptions> options )
    {
        this.activator = activator;
        this.sunsetPolicyManager = sunsetPolicyManager;
        this.providers = providers.ToArray();
        this.options = options;
    }

    public IApiVersionDescriptionProvider Create( EndpointDataSource endpointDataSource )
    {
        var collators = new List<IApiVersionMetadataCollationProvider>( capacity: providers.Length + 1 )
        {
            new EndpointApiVersionMetadataCollationProvider( endpointDataSource ),
        };

        collators.AddRange( providers );

        return activator( collators, sunsetPolicyManager, options );
   

Setup

Now you just need to replace these services in your setup.

builder.Services.AddSingleton<IReportApiVersions, NoPreviewApiVersionReporter>();
builder.Services.AddSingleton<IApiVersionDescriptionProvider, NoPreviewApiDescriptionProvider>();
builder.Services.AddTransient<IApiVersionDescriptionProviderFactory>(
    static provider =>
        new NoPreviewApiVersionDescriptionProviderFactory(
            static ( p, m, o ) => new NoPreviewApiDescriptionProvider( p, m, o ),
            provider.GetRequiredService<ISunsetPolicyManager>(),
            provider.GetServices<IApiVersionMetadataCollationProvider>(),
            provider.GetRequiredService<IOptions<ApiExplorerOptions>>() ) );

Conclusion

I verified this setup against the OpenApiExample and it works as expected. Make additional changes as you see fit. It's not zero work, but this is much simpler and easy to understand than mucking around with routing IMHO.

Comment options

Thanks @commonsensesoftware, looks like there are some really great extension points in this library!

To clarify, we do want to report the preview version, but not the “dummy” preview versions we would be adding to deprecated and stable version controllers if we followed your suggestion.

I don’t doubt we could tweak some of your above examples to work the way we need, but to clarify, the current solution we arrived at doesn’t muck around with routing. We use the simple middleware you suggested to alter the incoming requested version ahead of the default routing, only instead of a hardcoded mapping dictionary, we use the description provider to help us. This works for all our current implementations and doesn’t require them to make any changes (e.g. decorate controllers with additional versions). If we find issues, I’ll certainly take a look at the above again.

Thanks for your time and for explaining things so clearly.

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Q&A
Labels
None yet
4 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.