From 3feb63d20909f54c297ea2d8cb1a430fdf4d7466 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 23 Jul 2026 13:06:42 -0700 Subject: [PATCH 1/2] Fix extracting API version with status using URL segment method. Fixes #1187 --- .../Http/HttpRequestExtensions.cs | 98 ++++++++++++- .../src/Asp.Versioning.Http/ReleaseNotes.txt | 3 +- .../Routing/ApiVersionPolicyJumpTable.cs | 16 +- .../Http/HttpRequestExtensionsTest.cs | 137 ++++++++++++++++++ 4 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Http/HttpRequestExtensionsTest.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpRequestExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpRequestExtensions.cs index dd4b7f304..8821bd54e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpRequestExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpRequestExtensions.cs @@ -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; @@ -34,18 +37,16 @@ public bool TryGetApiVersionFromPath( [NotNullWhen( true )] out string? apiVersion ) where TList : IReadOnlyList { + 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 @@ -58,7 +59,14 @@ public bool TryGetApiVersionFromPath( 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 ) ) { @@ -84,6 +92,82 @@ public bool TryGetApiVersionFromPath( } } + return request.TryInferApiVersionFromSegment( out apiVersion ); + } + + /// + /// Attempts to infer the API version from the current request path by looking for at each segment. + /// + /// The raw API version, if retrieved. + /// True if the raw API version was retrieved; otherwise, false. + /// + /// + /// 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. + /// + /// This approach addresses at least two use cases when an API version is specified as a segment in the + /// URL path: + /// + /// + /// with a status + /// + /// + /// using gRPC + /// + /// + /// + /// + /// 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. + /// + /// + private bool TryInferApiVersionFromSegment( [NotNullWhen( true )] out string? apiVersion ) + { + if ( request.HttpContext.RequestServices.GetService() 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; } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt index 5f282702b..464e2030c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt @@ -1 +1,2 @@ - \ No newline at end of file +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) \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs index 29c93e3b6..83478864f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -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 ); } @@ -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; } @@ -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; + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Http/HttpRequestExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Http/HttpRequestExtensionsTest.cs new file mode 100644 index 000000000..8b7a6116c --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Http/HttpRequestExtensionsTest.cs @@ -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(); + + 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(); + var services = new Mock(); + var context = new Mock(); + var request = new Mock(); + + 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(); + var context = new Mock(); + var request = new Mock(); + + 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(); + var context = new Mock(); + var request = new Mock(); + + 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(); + var context = new Mock(); + var request = new Mock(); + + 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" ); + } +} \ No newline at end of file From 2d80382e2055fa91d84c4bba1a3d9f28d97cca1e Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 27 Jul 2026 17:00:00 -0700 Subject: [PATCH 2/2] Fix routing of unversioned endpoints --- .../WebApi/src/Asp.Versioning.Http/README.md | 2 +- .../Routing/AmbiguousApiVersionEndpoint.cs | 8 +- .../Routing/ApiVersionMatcherPolicy.cs | 59 +++++---- .../Routing/ClientErrorEndpoint.cs | 28 ++++ .../Routing/ClientErrorEndpointBuilder.cs | 4 +- .../Routing/EdgeBuilder.cs | 33 ++++- .../Asp.Versioning.Http/Routing/EdgeKey.cs | 2 + .../Routing/EndpointType.cs | 1 + .../Routing/MalformedApiVersionEndpoint.cs | 8 +- .../Routing/NotAcceptableEndpoint.cs | 12 +- .../Routing/RouteDestination.cs | 2 +- .../Routing/UnspecifiedApiVersionEndpoint.cs | 10 +- .../Routing/UnsupportedApiVersionEndpoint.cs | 13 +- .../Routing/UnsupportedMediaTypeEndpoint.cs | 12 +- .../Routing/ApiVersionMatcherPolicyTest.cs | 125 +++++++++++++++++- 15 files changed, 250 insertions(+), 69 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpoint.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md index 25a4fde99..7e4930635 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md @@ -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 - Asp.Versioning.QueryStringApiVersionReader ## Release Notes diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/AmbiguousApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/AmbiguousApiVersionEndpoint.cs index bb9632b14..2036b2a3a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/AmbiguousApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/AmbiguousApiVersionEndpoint.cs @@ -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 ) { diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index 137e598e6..def5bd83a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -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() ); @@ -146,9 +148,14 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList GetEdges( IReadOnlyList endpoints if ( endpoints[i] is not RouteEndpoint endpoint || endpoint.Metadata.GetMetadata() 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; } @@ -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: // @@ -334,16 +344,12 @@ private static void Collate( SortedSet? supported, SortedSet? 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 ) @@ -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++ ) { @@ -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(); 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: @@ -425,7 +438,7 @@ private static (bool Matched, bool HasCandidates) MatchApiVersion( CandidateSet matched = !implicitMatches.IsEmpty; } - return (matched, hasCandidates); + return (matched, hasCandidates, unversioned); } private ValueTask TrySelectApiVersionAsync( HttpContext httpContext, CandidateSet candidates ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpoint.cs new file mode 100644 index 000000000..538c52479 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpoint.cs @@ -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; + +/// +/// Creates the endpoints synthesized by the API versioning matcher policy to +/// report a client error. +/// +/// +/// 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 EndpointComparer, which orders any +/// endpoint that is not a before every endpoint that is. A client error endpoint is, +/// therefore, a with the lowest possible order so that it always sorts last and is only +/// selected when nothing else matched. +/// +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 ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs index 9b594f043..a32252640 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -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 ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs index 430159bab..d801ddfe5 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -19,6 +19,7 @@ internal sealed class EdgeBuilder private readonly Dictionary> edges; private readonly HashSet routePatterns = new( new RoutePatternComparer() ); private EdgeKey assumeDefault = EdgeKey.AssumeDefault; + private List? unversioned; public EdgeBuilder( int capacity, @@ -32,21 +33,41 @@ public EdgeBuilder( keys = new( capacity + 1 ); edges = new( capacity + RejectionEndpointCapacity ) { - [EdgeKey.Malformed] = [new MalformedApiVersionEndpoint( logger, options )], - [EdgeKey.Ambiguous] = [new AmbiguousApiVersionEndpoint( logger )], - [EdgeKey.Unspecified] = [new UnspecifiedApiVersionEndpoint( logger, options )], - [EdgeKey.Unsupported] = [new UnsupportedApiVersionEndpoint( options )], - [EdgeKey.UnsupportedMediaType] = [new UnsupportedMediaTypeEndpoint( options )], - [EdgeKey.NotAcceptable] = [new NotAcceptableEndpoint( options )], + [EdgeKey.Malformed] = [MalformedApiVersionEndpoint.New( logger, options )], + [EdgeKey.Ambiguous] = [AmbiguousApiVersionEndpoint.New( logger )], + [EdgeKey.Unspecified] = [UnspecifiedApiVersionEndpoint.New( logger, options )], + [EdgeKey.Unsupported] = [UnsupportedApiVersionEndpoint.New( options )], + [EdgeKey.UnsupportedMediaType] = [UnsupportedMediaTypeEndpoint.New( options )], + [EdgeKey.NotAcceptable] = [NotAcceptableEndpoint.New( options )], }; } public IReadOnlyList Build() { routePatterns.TrimExcess(); + + if ( unversioned is not null ) + { + // an endpoint without ApiVersionMetadata is not versioned and must never have an API versioning policy + // enforced against it. the endpoints of an edge become the candidates of the destination it jumps to, + // which means an endpoint that is omitted from an edge is unreachable. carry the unversioned endpoints + // into every edge so they remain a candidate for any destination the jump table can select. a client error + // endpoint always sorts last, so an unversioned endpoint that matches will be selected ahead of it + foreach ( var endpoints in edges.Values ) + { + endpoints.AddRange( unversioned ); + } + + // the jump table can also exit to the destination of the enclosing node; for example, a 404 when versioning + // by url segment only. that destination has no edge, so provide one that the policy can redirect the exit to + edges.Add( EdgeKey.Unversioned, [.. unversioned] ); + } + return [.. edges.Select( edge => new PolicyNodeEdge( edge.Key, edge.Value ) )]; } + public void AddUnversioned( Endpoint endpoint ) => ( unversioned ??= [] ).Add( endpoint ); + public void Add( RouteEndpoint endpoint ) { if ( unspecifiedAllowed ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs index 4e24067a6..786a19adc 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs @@ -46,6 +46,8 @@ internal EdgeKey( internal static EdgeKey AssumeDefault => new( EndpointType.AssumeDefault, new( new RoutePatternComparer() ) ); + internal static EdgeKey Unversioned => new( EndpointType.Unversioned, Set.Empty ); + public bool Equals( [AllowNull] EdgeKey other ) => GetHashCode() == other.GetHashCode(); public override bool Equals( object? obj ) => obj is EdgeKey other && Equals( other ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs index 73f0c1bd6..22d2fd13f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs @@ -12,4 +12,5 @@ internal enum EndpointType AssumeDefault, NotAcceptable, Unsupported, + Unversioned, } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/MalformedApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/MalformedApiVersionEndpoint.cs index fbafbd942..058d53fef 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/MalformedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/MalformedApiVersionEndpoint.cs @@ -4,16 +4,16 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using System.Globalization; -using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; -internal sealed class MalformedApiVersionEndpoint : Endpoint +internal static class MalformedApiVersionEndpoint { private const string Name = "400 Invalid API Version"; - internal MalformedApiVersionEndpoint( ILogger logger, ApiVersioningOptions options ) - : base( context => OnExecute( context, options, logger ), Empty, Name ) { } + internal static RouteEndpoint New( ILogger logger, ApiVersioningOptions options ) => + ClientErrorEndpoint.New( context => OnExecute( context, options, logger ), Name ); private static Task OnExecute( HttpContext context, ApiVersioningOptions options, ILogger logger ) { diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs index 5ec963e6b..075b34fb2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs @@ -3,19 +3,17 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; -using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; +using Microsoft.AspNetCore.Routing; -internal sealed class NotAcceptableEndpoint : Endpoint +internal static class NotAcceptableEndpoint { private const string Name = "406 HTTP Not Acceptable"; - internal NotAcceptableEndpoint( ApiVersioningOptions options ) - : base( + internal static RouteEndpoint New( ApiVersioningOptions options ) => + ClientErrorEndpoint.New( context => EndpointProblem.UnsupportedApiVersion( context, options, StatusCodes.Status406NotAcceptable ), - Empty, - Name ) - { } + Name ); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs index 39dcbb64b..8c2ec022b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs @@ -4,7 +4,7 @@ namespace Asp.Versioning.Routing; internal struct RouteDestination { - public readonly int Exit; + public int Exit; public int Malformed; public int Ambiguous; public int Unspecified; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnspecifiedApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnspecifiedApiVersionEndpoint.cs index 7c861b881..d1535f1af 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnspecifiedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnspecifiedApiVersionEndpoint.cs @@ -3,18 +3,18 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; -internal sealed class UnspecifiedApiVersionEndpoint : Endpoint +internal static class UnspecifiedApiVersionEndpoint { private const string Name = "400 Unspecified API Version"; - internal UnspecifiedApiVersionEndpoint( + internal static RouteEndpoint New( ILogger logger, ApiVersioningOptions options, - string[]? displayNames = default ) - : base( context => OnExecute( context, options, displayNames, logger ), Empty, Name ) { } + string[]? displayNames = default ) => + ClientErrorEndpoint.New( context => OnExecute( context, options, displayNames, logger ), Name ); private static Task OnExecute( HttpContext context, diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs index 1cd83a42f..f5c4d98c5 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs @@ -2,20 +2,17 @@ namespace Asp.Versioning.Routing; -using Microsoft.AspNetCore.Http; -using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; +using Microsoft.AspNetCore.Routing; -internal sealed class UnsupportedApiVersionEndpoint : Endpoint +internal static class UnsupportedApiVersionEndpoint { private const string Name = " Unsupported API Version"; - internal UnsupportedApiVersionEndpoint( ApiVersioningOptions options ) - : base( + internal static RouteEndpoint New( ApiVersioningOptions options ) => + ClientErrorEndpoint.New( context => EndpointProblem.UnsupportedApiVersion( context, options, options.UnsupportedApiVersionStatusCode ), - Empty, - options.UnsupportedApiVersionStatusCode + Name ) - { } + options.UnsupportedApiVersionStatusCode + Name ); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs index 22ec254cf..b83d63cc9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs @@ -3,19 +3,17 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; -using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; +using Microsoft.AspNetCore.Routing; -internal sealed class UnsupportedMediaTypeEndpoint : Endpoint +internal static class UnsupportedMediaTypeEndpoint { private const string Name = "415 HTTP Unsupported Media Type"; - internal UnsupportedMediaTypeEndpoint( ApiVersioningOptions options ) - : base( + internal static RouteEndpoint New( ApiVersioningOptions options ) => + ClientErrorEndpoint.New( context => EndpointProblem.UnsupportedApiVersion( context, options, StatusCodes.Status415UnsupportedMediaType ), - Empty, - Name ) - { } + Name ); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs index 3df00a291..6d9a57336 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Routing/ApiVersionMatcherPolicyTest.cs @@ -2,7 +2,6 @@ namespace Asp.Versioning.Routing; -using Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Routing; @@ -227,6 +226,130 @@ public async Task apply_should_have_candidate_for_unspecified_api_version() feature.Object.RequestedApiVersion.Should().Be( new ApiVersion( 1, 0 ) ); } + [Fact] + public void get_edges_should_keep_unversioned_endpoint_reachable_from_every_edge() + { + // arrange + var policy = NewApiVersionMatcherPolicy(); + var model = new ApiVersionModel( new ApiVersion( 1, 0 ) ); + var versioned = new RouteEndpointBuilder( Limbo, RoutePatternFactory.Parse( "{a}/{b}" ), 0 ) + { + DisplayName = "Versioned", + Metadata = { new ApiVersionMetadata( model, model ) }, + }.Build(); + var unversioned = new RouteEndpointBuilder( Limbo, RoutePatternFactory.Parse( "scalar/scalar.js" ), 0 ) + { + DisplayName = "Unversioned", + }.Build(); + + // act + var edges = policy.GetEdges( [versioned, unversioned] ); + + // assert + edges.Should().OnlyContain( edge => edge.Endpoints.Contains( unversioned ) ); + } + + [Fact] + public void build_jump_table_should_exit_to_unversioned_endpoints() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, default ); + feature.SetupProperty( f => f.RawRequestedApiVersions, [] ); + + // versioning by url segment only reports 404 instead of 400 when the version is unspecified, + // which exits the node. the unversioned endpoints must be reachable from that destination + var options = new ApiVersioningOptions() + { + ApiVersionReader = new UrlSegmentApiVersionReader(), + }; + var policy = NewApiVersionMatcherPolicy( options ); + var model = new ApiVersionModel( new ApiVersion( 1, 0 ) ); + var versioned = new RouteEndpointBuilder( Limbo, RoutePatternFactory.Parse( "{a}/{b}" ), 0 ) + { + DisplayName = "Versioned", + Metadata = { new ApiVersionMetadata( model, model ) }, + }.Build(); + var unversioned = new RouteEndpointBuilder( Limbo, RoutePatternFactory.Parse( "scalar/scalar.js" ), 0 ) + { + DisplayName = "Unversioned", + }.Build(); + var edges = policy.GetEdges( [versioned, unversioned] ); + var tableEdges = new List(); + + for ( var i = 0; i < edges.Count; i++ ) + { + tableEdges.Add( new( edges[i].State, i ) ); + } + + var jumpTable = policy.BuildJumpTable( 42, tableEdges ); + var httpContext = NewHttpContext( feature ); + + // act + var destination = jumpTable.GetDestination( httpContext ); + + // assert + destination.Should().NotBe( 42 ); + edges[destination].Endpoints.Should().Equal( unversioned ); + } + + [Fact] + public void client_error_endpoint_should_sort_after_unversioned_endpoint() + { + // arrange + var policy = NewApiVersionMatcherPolicy(); + var model = new ApiVersionModel( new ApiVersion( 1, 0 ) ); + var versioned = new RouteEndpointBuilder( Limbo, RoutePatternFactory.Parse( "{a}/{b}" ), 0 ) + { + DisplayName = "Versioned", + Metadata = { new ApiVersionMetadata( model, model ) }, + }.Build(); + var unversioned = new RouteEndpointBuilder( Limbo, RoutePatternFactory.Parse( "scalar/scalar.js" ), 0 ) + { + DisplayName = "Unversioned", + }.Build(); + var edges = policy.GetEdges( [versioned, unversioned] ); + var edge = edges.Single( e => e.State.ToString() == "VER: Unspecified" ); + + // act + var clientError = edge.Endpoints.Single( e => e != unversioned ); + + // assert + // the routing system sorts a non-RouteEndpoint ahead of every RouteEndpoint, which would make + // a client error the highest priority candidate. it must be a RouteEndpoint with the lowest + // possible order so an endpoint that is not versioned is always selected ahead of it + clientError.Should() + .BeOfType() + .Which.Order.Should().Be( int.MaxValue ); + } + + [Fact] + public async Task apply_should_not_use_400_endpoint_for_unversioned_candidate() + { + // arrange + var feature = new Mock(); + + feature.SetupProperty( f => f.RawRequestedApiVersion, default ); + feature.SetupProperty( f => f.RawRequestedApiVersions, [] ); + feature.SetupProperty( f => f.RequestedApiVersion, default ); + + var policy = NewApiVersionMatcherPolicy(); + var model = new ApiVersionModel( new ApiVersion( 1, 0 ) ); + var items = new object[] { new ApiVersionMetadata( model, model ) }; + var versioned = new Endpoint( Limbo, new( items ), "Versioned" ); + var unversioned = new Endpoint( Limbo, new(), "Unversioned" ); + var candidates = new CandidateSet( [versioned, unversioned], [[], []], [0, 1] ); + var httpContext = NewHttpContext( feature ); + + // act + await policy.ApplyAsync( httpContext, candidates ); + + // assert + httpContext.GetEndpoint().Should().BeNull(); + candidates.IsValidCandidate( 1 ).Should().BeTrue(); + } + private static Task Limbo( HttpContext context ) => Task.CompletedTask; private static ApiVersionMatcherPolicy NewApiVersionMatcherPolicy( ApiVersioningOptions options = default ) =>