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
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 @@ -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,137 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

#pragma warning disable IDE0130

namespace Microsoft.AspNetCore.Http;

using Asp.Versioning;
using Asp.Versioning.Routing;
using Microsoft.AspNetCore.Routing.Patterns;

public class HttpRequestExtensionsTest
{
[Fact]
public void try_get_api_version_from_path_should_extract_from_route_pattern()
{
// arrange
var pattern = RoutePatternFactory.Parse(
"v{version:apiVersion}/test",
default,
new { version = new ApiVersionRouteConstraint() } );
var patterns = new[] { pattern };
var request = new Mock<HttpRequest>();

request.SetupProperty( r => r.Path, new PathString( "/v2/test" ) );

// act
var matched = request.Object.TryGetApiVersionFromPath( patterns, "apiVersion", out var apiVersion );

// assert
matched.Should().BeTrue();
apiVersion.Should().Be( "2" );
}

[Fact]
public void try_get_api_version_from_path_should_infer_from_segment()
{
// arrange
var patterns = Array.Empty<RoutePattern>();
var services = new Mock<IServiceProvider>();
var context = new Mock<HttpContext>();
var request = new Mock<HttpRequest>();

services.Setup( sp => sp.GetService( typeof( IApiVersionParser ) ) ).Returns( new ApiVersionParser() );
context.SetupProperty( c => c.RequestServices, services.Object );
request.SetupProperty( r => r.Path, new PathString( "/v2/test" ) );
request.SetupGet( r => r.HttpContext ).Returns( context.Object );

// act
var matched = request.Object.TryGetApiVersionFromPath( patterns, "apiVersion", out var apiVersion );

// assert
matched.Should().BeTrue();
apiVersion.Should().Be( "2" );
}

[Theory]
[InlineData( "/v2/test" )]
[InlineData( "/V2/test" )]
[InlineData( "/api/v2/test" )]
[InlineData( "/api/V2/test" )]
public void try_get_api_version_from_path_should_fall_back_to_infer_from_segment( string path )
{
// arrange
var pattern = RoutePatternFactory.Parse(
"v1/test",
default,
new { version = new ApiVersionRouteConstraint() } );
var patterns = new[] { pattern };
var services = new Mock<IServiceProvider>();
var context = new Mock<HttpContext>();
var request = new Mock<HttpRequest>();

services.Setup( sp => sp.GetService( typeof( IApiVersionParser ) ) ).Returns( new ApiVersionParser() );
context.SetupProperty( c => c.RequestServices, services.Object );
request.SetupProperty( r => r.Path, new PathString( path ) );
request.SetupGet( r => r.HttpContext ).Returns( context.Object );

// act
var matched = request.Object.TryGetApiVersionFromPath( patterns, "apiVersion", out var apiVersion );

// assert
matched.Should().BeTrue();
apiVersion.Should().Be( "2" );
}

[Fact]
public void try_get_api_version_from_path_should_not_match()
{
// arrange
var pattern = RoutePatternFactory.Parse(
"v1/test",
default,
new { version = new ApiVersionRouteConstraint() } );
var patterns = new[] { pattern };
var services = new Mock<IServiceProvider>();
var context = new Mock<HttpContext>();
var request = new Mock<HttpRequest>();

services.Setup( sp => sp.GetService( typeof( IApiVersionParser ) ) ).Returns( new ApiVersionParser() );
context.SetupProperty( c => c.RequestServices, services.Object );
request.SetupProperty( r => r.Path, new PathString( "/test" ) );
request.SetupGet( r => r.HttpContext ).Returns( context.Object );

// act
var matched = request.Object.TryGetApiVersionFromPath( patterns, "apiVersion", out var apiVersion );

// assert
matched.Should().BeFalse();
apiVersion.Should().BeNull();
}

[Fact]
public void try_get_api_version_from_path_should_match_version_with_status()
{
// arrange
var pattern = RoutePatternFactory.Parse(
"v2-preview/test",
default,
new { version = new ApiVersionRouteConstraint() } );
var patterns = new[] { pattern };
var services = new Mock<IServiceProvider>();
var context = new Mock<HttpContext>();
var request = new Mock<HttpRequest>();

services.Setup( sp => sp.GetService( typeof( IApiVersionParser ) ) ).Returns( new ApiVersionParser() );
context.SetupProperty( c => c.RequestServices, services.Object );
request.SetupProperty( r => r.Path, new PathString( "/v2-preview/test" ) );
request.SetupGet( r => r.HttpContext ).Returns( context.Object );

// act
var matched = request.Object.TryGetApiVersionFromPath( patterns, "apiVersion", out var apiVersion );

// assert
matched.Should().BeTrue();
apiVersion.Should().Be( "2-preview" );
}
}
Loading