Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace Microsoft.AspNetCore.Http;

using Asp.Versioning;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Template;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using System.ComponentModel;
using RoutePattern = Microsoft.AspNetCore.Routing.Patterns.RoutePattern;

Expand Down Expand Up @@ -34,18 +37,16 @@ public bool TryGetApiVersionFromPath<TList>(
[NotNullWhen( true )] out string? apiVersion )
where TList : IReadOnlyList<RoutePattern>
{
ArgumentNullException.ThrowIfNull( request );
ArgumentNullException.ThrowIfNull( routePatterns );

if ( string.IsNullOrEmpty( constraintName ) || routePatterns.Count == 0 )
{
apiVersion = default;
return false;
return request.TryInferApiVersionFromSegment( out apiVersion );
}

#pragma warning disable CA2208 // Instantiate argument exceptions correctly
var path = ( request ?? throw new ArgumentNullException( nameof( request ) ) ).Path;
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
var values = new RouteValueDictionary();
var path = request.Path;
var values = default( RouteValueDictionary );

// this only applies when versioning by url segment. route values have not been processed
// since no candidates exist yet. we do know the name of the route constraint though. there
Expand All @@ -58,7 +59,14 @@ public bool TryGetApiVersionFromPath<TList>(
var defaults = new RouteValueDictionary( routePattern.RequiredValues );
var matcher = new TemplateMatcher( new( routePattern ), defaults );

values.Clear();
if ( values is null )
{
values = [];
}
else
{
values.Clear();
}

if ( !matcher.TryMatch( path, values ) )
{
Expand All @@ -84,6 +92,82 @@ public bool TryGetApiVersionFromPath<TList>(
}
}

return request.TryInferApiVersionFromSegment( out apiVersion );
}

/// <summary>
/// Attempts to infer the API version from the current request path by looking for at each segment.
/// </summary>
/// <param name="apiVersion">The raw API version, if retrieved.</param>
/// <returns>True if the raw API version was retrieved; otherwise, false.</returns>
/// <remarks>
/// <para>
/// This is the last resort for inferring the API version and is intrinsically slow. There are no route
/// constraints to match against. '/v1' and 'api/v1' are the only recognized and supported prefixes as
/// versioning by URL segment is uniformly at the beginning of the path and a later segment in the path could
/// be accidentally misinterpreted.
/// </para>
/// <para>This approach addresses at least two use cases when an API version is specified as a segment in the
/// URL path:
/// <list type="bullet">
/// <item>
/// <description>with a status</description>
/// </item>
/// <item>
/// <description>using gRPC</description>
/// </item>
/// </list>
/// </para>
/// <para>
/// An alternate design could support configuring a route template for the purposes of matching, but that would
/// only address the gRPC scenario. The path 'v2-preview' or 'api/v2-preview' would still not match.
/// </para>
/// </remarks>
private bool TryInferApiVersionFromSegment( [NotNullWhen( true )] out string? apiVersion )
{
if ( request.HttpContext.RequestServices.GetService<IApiVersionParser>() is not { } parser )
{
apiVersion = default;
return false;
}

var segments = new StringTokenizer( request.Path, ['/'] );
var count = 0;

foreach ( var segment in segments )
{
switch ( segment.Length )
{
case 0:
continue;
case 1:
goto NoMatch;
default:
++count;
break;
}

if ( count > 2 )
{
break;
}

var span = segment.AsSpan();

if ( span[0] == 'v' || span[0] == 'V' )
{
span = span[1..];

if ( parser.TryParse( span, out var _ ) )
{
apiVersion = span.ToString();
return true;
}
}
}

NoMatch:

apiVersion = default;
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Minimal APIs. For additional functionality provided by ASP.NET Core MVC use the
- Asp.Versioning.IApiVersionDescriptionProvider
- Asp.Versioning.IApiVersionSelector
- Asp.Versioning.IReportApiVersions
- Asp.Versioning.IDeprecationPolicyBuilder
- Asp.Versioning.ISunsetPolicyBuilder
- Asp.Versioning.IPolicyManager<SunsetPolicy>
- Asp.Versioning.QueryStringApiVersionReader

## Release Notes
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@

Fix API version extraction in URLs with status [Issue #1187](https://github.com/dotnet/aspnet-api-versioning/issues/1187)
Support versioning by URL segment for gRPC services [Issue #](https://github.com/dotnet/aspnet-api-versioning/issues/1109)
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
namespace Asp.Versioning.Routing;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using System.Globalization;
using static Microsoft.AspNetCore.Http.EndpointMetadataCollection;

internal sealed class AmbiguousApiVersionEndpoint : Endpoint
internal static class AmbiguousApiVersionEndpoint
{
private const string Name = "400 Ambiguous API Version";

internal AmbiguousApiVersionEndpoint( ILogger logger )
: base( c => OnExecute( c, logger ), Empty, Name ) { }
internal static RouteEndpoint New( ILogger logger ) =>
ClientErrorEndpoint.New( c => OnExecute( c, logger ), Name );

private static Task OnExecute( HttpContext context, ILogger logger )
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ public async Task ApplyAsync( HttpContext httpContext, CandidateSet candidates )
feature.RequestedApiVersion = apiVersion;
}

var (matched, hasCandidates) = MatchApiVersion( candidates, apiVersion );
var (matched, hasCandidates, unversioned) = MatchApiVersion( candidates, apiVersion );

if ( !matched && hasCandidates && !DifferByRouteConstraintsOnly( candidates ) )
// an unversioned candidate is still a valid match. replacing the endpoint with a client error
// would enforce an api versioning policy against an endpoint that never opted into versioning
if ( !matched && hasCandidates && !unversioned && !DifferByRouteConstraintsOnly( candidates ) )
{
var builder = new ClientErrorEndpointBuilder( feature, candidates, Options, logger );
httpContext.SetEndpoint( builder.Build() );
Expand Down Expand Up @@ -146,9 +148,14 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList<Policy
case EndpointType.NotAcceptable:
rejection.NotAcceptable = edge.Destination;
break;
case EndpointType.Unversioned:
// the edge only exists when the node contains one or more endpoints that are not versioned. exiting
// the node would make them unreachable, so exit to them instead. if none of them match, the result
// is the same 404 the exit destination produces
rejection.Exit = edge.Destination;
break;
default:
// the route patterns provided to each edge is a
// singleton so any edge will do
// the route patterns provided to each edge is a singleton so any edge will do
routePatterns ??= [.. state.RoutePatterns];
destinations.Add( state.ApiVersion, edge.Destination );
break;
Expand Down Expand Up @@ -182,6 +189,10 @@ public IReadOnlyList<PolicyNodeEdge> GetEdges( IReadOnlyList<Endpoint> endpoints
if ( endpoints[i] is not RouteEndpoint endpoint ||
endpoint.Metadata.GetMetadata<ApiVersionMetadata>() is not ApiVersionMetadata metadata )
{
// the endpoint is not versioned. it can only appear here because it overlapped with a versioned
// endpoint in the route table; for example, a static asset that happens to match a catch-all template.
// api versioning policies must not be enforced against it, but dropping it here would make it unreachable
builder.AddUnversioned( endpoints[i] );
continue;
}

Expand Down Expand Up @@ -244,16 +255,15 @@ private static bool DifferByRouteConstraintsOnly( CandidateSet candidates )
return false;
}

// HACK: edge case where the only differences are route template semantics.
// the established behavior is 400 when an endpoint 'could' match, but doesn't.
// this will not work for the scenario:
// HACK: edge case where the only differences are route template semantics. the established behavior is 400 when
// an endpoint 'could' match, but doesn't. this will not work for the scenario:
//
// * 1.0 = values/{id}
// * 2.0 = values/{id:int}
//
// Where the requested version is 2.0 and {id} is 'abc'. Users expect 404 in this
// scenario. Both candidates have been eliminated, but the policy doesn't know why.
// the only differences are route constraints; otherwise, the templates are equivalent.
// Where the requested version is 2.0 and {id} is 'abc'. Users expect 404 in this scenario. Both candidates have
// been eliminated, but the policy doesn't know why. the only differences are route constraints; otherwise, the
// templates are equivalent.
//
// for the scenario:
//
Expand Down Expand Up @@ -334,16 +344,12 @@ private static void Collate(
SortedSet<ApiVersion>? supported,
SortedSet<ApiVersion>? deprecated )
{
// this is a best guess effort at collating all supported and deprecated
// versions for an api when unmatched and it needs to be reported. it's
// impossible to be sure as there is no way to correlate an arbitrary
// request url by endpoint or name. the routing system will build a tree
// based on the route template before the jump table policy is created,
// which provides a natural method of grouping. manual, contrived tests
// demonstrated that were the results were correctly collated together.
// it is possible there is an edge case that isn't covered, but it's
// unclear what that would look like. one or more test cases should be
// added to document that is discovered
// this is a best guess effort at collating all supported and deprecated versions for an api when unmatched and
// it needs to be reported. it's impossible to be sure as there is no way to correlate an arbitrary request url
// by endpoint or name. the routing system will build a tree based on the route template before the jump table
// policy is created, which provides a natural method of grouping. manual, contrived tests demonstrated that
// were the results were correctly collated together. it is possible there is an edge case that isn't covered,
// but it's unclear what that would look like. one or more test cases should be added to document that if discovered
ApiVersionModel model;

if ( supported == null )
Expand All @@ -368,12 +374,15 @@ private static void Collate(
return new( new( model, model ) );
}

private static (bool Matched, bool HasCandidates) MatchApiVersion( CandidateSet candidates, ApiVersion? apiVersion )
private static (bool Matched, bool HasCandidates, bool Unversioned) MatchApiVersion(
CandidateSet candidates,
ApiVersion? apiVersion )
{
var total = candidates.Count;
var matched = false;
var implicitMatches = new Matches( stackalloc int[total] );
var hasCandidates = false;
var unversioned = false;

for ( var i = 0; i < total; i++ )
{
Expand All @@ -382,15 +391,19 @@ private static (bool Matched, bool HasCandidates) MatchApiVersion( CandidateSet
continue;
}

hasCandidates = true;
ref readonly var candidate = ref candidates[i];
var metadata = candidate.Endpoint.Metadata.GetMetadata<ApiVersionMetadata>();

if ( metadata == null )
{
// the candidate is not versioned. leave it valid and remember that endpoint
// selection has something to fall back on other than a client error
unversioned = true;
continue;
}

hasCandidates = true;

switch ( metadata.MappingTo( apiVersion ) )
{
case Explicit:
Expand Down Expand Up @@ -425,7 +438,7 @@ private static (bool Matched, bool HasCandidates) MatchApiVersion( CandidateSet
matched = !implicitMatches.IsEmpty;
}

return (matched, hasCandidates);
return (matched, hasCandidates, unversioned);
}

private ValueTask<ApiVersion> TrySelectApiVersionAsync( HttpContext httpContext, CandidateSet candidates )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ internal ApiVersionPolicyJumpTable(
this.routePatterns = routePatterns;
this.parser = parser;
this.options = options;
versionsByUrl = routePatterns.Length > 0;

// grpc does not support route parameter constraints in the same way as other endpoints, which means there will
// not be a route pattern for a grpc endpoint that versions by url segment. grpc endpoints can also be versioned
// by query string. only add to the cost of extracting the api version from a url segment if that method of
// of versioning is enabled in combination with grpc
versionsByUrl = routePatterns.Length > 0 || ( Grpc.IsSupported && source.VersionsByUrl() );
versionsByUrlOnly = source.VersionsByUrl( allowMultipleLocations: false );
versionsByMediaTypeOnly = source.VersionsByMediaType( allowMultipleLocations: false );
}
Expand All @@ -54,6 +59,7 @@ public override int GetDestination( HttpContext httpContext )
TryGetApiVersionFromPath( request, out var rawApiVersion ) &&
DoesNotContainApiVersion( apiVersions, rawApiVersion ) )
{
feature.RawRequestedApiVersion = rawApiVersion;
apiVersions.Add( rawApiVersion );
addedFromUrl = apiVersions.Count == apiVersions.Capacity;
}
Expand Down Expand Up @@ -159,4 +165,12 @@ private bool AreEquivalentSlow( string rawApiVersion, string otherRawApiVersion
[MethodImpl( MethodImplOptions.AggressiveInlining )]
private bool TryGetApiVersionFromPath( HttpRequest request, [NotNullWhen( true )] out string? apiVersion ) =>
request.TryGetApiVersionFromPath( routePatterns, options.RouteConstraintName, out apiVersion );

// REF: https://github.com/grpc/grpc-dotnet/blob/master/src/Grpc.AspNetCore.Server/Internal/GrpcMarkerService.cs
// REF: https://github.com/grpc/grpc-dotnet/blob/master/src/Grpc.AspNetCore.Server/GrpcServiceExtensions.cs#L71
private static class Grpc
{
private const string MarkerTypeName = "Grpc.AspNetCore.Server.Internal.GrpcMarkerService, Grpc.AspNetCore.Server";
public static readonly bool IsSupported = Type.GetType( MarkerTypeName ) is not null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning.Routing;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;

/// <summary>
/// Creates the endpoints synthesized by the API versioning <see cref="ApiVersionMatcherPolicy">matcher policy</see> to
/// report a client error.
/// </summary>
/// <remarks>
/// A client error endpoint is always a fallback; it must never win candidate selection over a real endpoint it happens
/// to be grouped with. The routing system sorts the endpoints of a node with <c>EndpointComparer</c>, which orders any
/// endpoint that is not a <see cref="RouteEndpoint"/> before every endpoint that is. A client error endpoint is,
/// therefore, a <see cref="RouteEndpoint"/> with the lowest possible order so that it always sorts last and is only
/// selected when nothing else matched.
/// </remarks>
internal static class ClientErrorEndpoint
{
private static RoutePattern? empty;

private static RoutePattern Empty => empty ??= RoutePatternFactory.Parse( string.Empty );

internal static RouteEndpoint New( RequestDelegate requestDelegate, string displayName ) =>
new( requestDelegate, Empty, int.MaxValue, EndpointMetadataCollection.Empty, displayName );
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ public Endpoint Build()
{
if ( feature.RawRequestedApiVersions.Count == 0 )
{
return new UnspecifiedApiVersionEndpoint( logger, options, GetDisplayNames() );
return UnspecifiedApiVersionEndpoint.New( logger, options, GetDisplayNames() );
}

return new UnsupportedApiVersionEndpoint( options );
return UnsupportedApiVersionEndpoint.New( options );
}

private static string DisplayName( Endpoint endpoint )
Expand Down
Loading
Loading