From 3660a24c4f0a7833e668c1ec2a2b5cb43a931545 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 16 Nov 2025 13:43:50 -0800 Subject: [PATCH 1/2] Add editor guidelines --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index 68505e1c..6e6a94e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,7 @@ root = true # don't use tabs for indentation [*] indent_style = space +guidelines = 120 1px solid yellow vsspell_section_id = 41b65011239a40959ccaae2a4ec7044a vsspell_ignored_words_41b65011239a40959ccaae2a4ec7044a = Accessor|app|clr|Edm|inline|middleware|Mvc|odata|Validator|Deconstruct From 368dd5cdc262245a1b93fb2cbb79517252b4095b Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 16 Nov 2025 13:44:56 -0800 Subject: [PATCH 2/2] Report API versions when unspecified or malformed. Fixes #1120 --- .../Http/MinimalApiFixture.cs | 1 + .../when using a query string.cs | 50 +++++++++++++++++++ ...a query string and split into two types.cs | 4 ++ .../Routing/ApiVersionMatcherPolicy.cs | 4 +- .../Routing/ApiVersionPolicyJumpTable.cs | 4 ++ .../Routing/ClientErrorEndpointBuilder.cs | 2 +- .../Routing/EdgeBuilder.cs | 12 ++--- .../Routing/EndpointProblem.cs | 22 +++++--- .../Routing/MalformedApiVersionEndpoint.cs | 8 +-- .../Routing/UnspecifiedApiVersionEndpoint.cs | 17 +++++-- 10 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs index 20789427..e971646f 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs @@ -57,6 +57,7 @@ protected override void OnConfigureEndpoints( IEndpointRouteBuilder endpoints ) protected override void OnAddApiVersioning( ApiVersioningOptions options ) { + options.ReportApiVersions = true; options.ApiVersionReader = ApiVersionReader.Combine( new QueryStringApiVersionReader(), new UrlSegmentApiVersionReader(), diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using a query string.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using a query string.cs index 2823e74b..85016516 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using a query string.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using a query string.cs @@ -4,6 +4,7 @@ namespace given_a_versioned_minimal_API; using Asp.Versioning; using Asp.Versioning.Http; +using static System.Net.HttpStatusCode; [Collection( nameof( MinimalApiTestCollection ) )] public class when_using_a_query_string : AcceptanceTest @@ -34,9 +35,58 @@ public async Task then_get_should_report_api_versions() var response = await GetAsync( "api/values?api-version=1.0" ); // assert + response.StatusCode.Should().Be( OK ); response.Headers.GetValues( "api-supported-versions" ).Should().Equal( "1.0, 2.0" ); } + [Fact] + public async Task then_get_should_return_400_for_an_unsupported_version() + { + // arrange + + + // act + var response = await GetAsync( "api/values?api-version=3.0" ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); + + // assert + response.StatusCode.Should().Be( BadRequest ); + response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0" ); + problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); + } + + [Fact] + public async Task then_get_should_return_400_for_an_unspecified_version() + { + // arrange + + + // act + var response = await GetAsync( "api/values" ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); + + // assert + response.StatusCode.Should().Be( BadRequest ); + response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0" ); + problem.Type.Should().Be( ProblemDetailsDefaults.Unspecified.Type ); + } + + [Fact] + public async Task then_get_should_return_400_for_a_malformed_version() + { + // arrange + + + // act + var response = await GetAsync( "api/values?api-version=abc" ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); + + // assert + response.StatusCode.Should().Be( BadRequest ); + response.Headers.GetValues( "api-supported-versions" ).Should().Equal( "1.0, 2.0" ); + problem.Type.Should().Be( ProblemDetailsDefaults.Invalid.Type ); + } + public when_using_a_query_string( MinimalApiFixture fixture, ITestOutputHelper console ) : base( fixture ) => console.WriteLine( fixture.DirectedGraphVisualizationUrl ); } \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when using a query string and split into two types.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when using a query string and split into two types.cs index 7c78efd4..7b1a868c 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when using a query string and split into two types.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when using a query string and split into two types.cs @@ -94,9 +94,12 @@ public async Task then_get_should_return_400_for_an_unsupported_version() // act var response = await GetAsync( "api/values?api-version=3.0" ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert response.StatusCode.Should().Be( BadRequest ); + response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0" ); + problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } [Fact] @@ -111,6 +114,7 @@ public async Task then_get_should_return_400_for_an_unspecified_version() // assert response.StatusCode.Should().Be( BadRequest ); + response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0" ); problem.Type.Should().Be( ProblemDetailsDefaults.Unspecified.Type ); } 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 e43223aa..8530e5a4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -337,14 +337,14 @@ private static void Collate( { // 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 sure as there is no way to correlate an arbitrary + // 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 + // added to document that is discovered ApiVersionModel model; if ( supported == null ) 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 24bb4a23..e2d32a26 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -76,6 +76,8 @@ public override int GetDestination( HttpContext httpContext ) return rejection.AssumeDefault; } + httpContext.Features.Set( policyFeature ); + // 3. unspecified return versionsByUrlOnly /* 404 */ ? rejection.Exit @@ -86,6 +88,8 @@ public override int GetDestination( HttpContext httpContext ) if ( !parser.TryParse( rawApiVersion, out var apiVersion ) ) { + httpContext.Features.Set( policyFeature ); + if ( versionsByUrl ) { feature.RawRequestedApiVersion = rawApiVersion; 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 cad20584..9b594f04 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -30,7 +30,7 @@ public Endpoint Build() { if ( feature.RawRequestedApiVersions.Count == 0 ) { - return new UnspecifiedApiVersionEndpoint( logger, GetDisplayNames() ); + return new UnspecifiedApiVersionEndpoint( logger, options, GetDisplayNames() ); } return new UnsupportedApiVersionEndpoint( options ); 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 8fb60798..621cf185 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -32,12 +32,12 @@ public EdgeBuilder( keys = new( capacity + 1 ); edges = new( capacity + RejectionEndpointCapacity ) { - [EdgeKey.Malformed] = new( capacity: 1 ) { new MalformedApiVersionEndpoint( logger ) }, - [EdgeKey.Ambiguous] = new( capacity: 1 ) { new AmbiguousApiVersionEndpoint( logger ) }, - [EdgeKey.Unspecified] = new( capacity: 1 ) { new UnspecifiedApiVersionEndpoint( logger ) }, - [EdgeKey.Unsupported] = new( capacity: 1 ) { new UnsupportedApiVersionEndpoint( options ) }, - [EdgeKey.UnsupportedMediaType] = new( capacity: 1 ) { new UnsupportedMediaTypeEndpoint( options ) }, - [EdgeKey.NotAcceptable] = new( capacity: 1 ) { new NotAcceptableEndpoint( options ) }, + [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 )], }; } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs index 9a8b539b..04c99c05 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs @@ -33,20 +33,30 @@ internal static ProblemDetailsContext New( HttpContext context, ProblemDetailsIn return newContext; } - internal static Task UnsupportedApiVersion( - HttpContext context, - ApiVersioningOptions options, - int statusCode ) + internal static bool TryReportApiVersions( HttpContext context, ApiVersioningOptions options ) { - context.Response.StatusCode = statusCode; - if ( options.ReportApiVersions && context.Features.Get() is ApiVersionPolicyFeature feature ) { var reporter = context.RequestServices.GetRequiredService(); var model = feature.Metadata.Map( reporter.Mapping ); context.Response.OnStarting( ReportApiVersions, (reporter, context.Response, model) ); + return true; + } + else + { + return false; } + } + + internal static Task UnsupportedApiVersion( + HttpContext context, + ApiVersioningOptions options, + int statusCode ) + { + context.Response.StatusCode = statusCode; + + TryReportApiVersions( context, options ); if ( context.TryGetProblemDetailsService( out var problemDetails ) ) { 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 c129794a..5c777eb6 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/MalformedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/MalformedApiVersionEndpoint.cs @@ -12,16 +12,18 @@ internal sealed class MalformedApiVersionEndpoint : Endpoint { private const string Name = "400 Invalid API Version"; - internal MalformedApiVersionEndpoint( ILogger logger ) - : base( c => OnExecute( c, logger ), Empty, Name ) { } + internal MalformedApiVersionEndpoint( ILogger logger, ApiVersioningOptions options ) + : base( context => OnExecute( context, options, logger ), Empty, Name ) { } - private static Task OnExecute( HttpContext context, ILogger logger ) + private static Task OnExecute( HttpContext context, ApiVersioningOptions options, ILogger logger ) { var requestedVersion = context.ApiVersioningFeature().RawRequestedApiVersion; logger.ApiVersionInvalid( requestedVersion ); context.Response.StatusCode = StatusCodes.Status400BadRequest; + EndpointProblem.TryReportApiVersions( context, options ); + if ( !context.TryGetProblemDetailsService( out var problemDetails ) ) { return Task.CompletedTask; 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 6597aec9..7c861b88 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnspecifiedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnspecifiedApiVersionEndpoint.cs @@ -10,10 +10,17 @@ internal sealed class UnspecifiedApiVersionEndpoint : Endpoint { private const string Name = "400 Unspecified API Version"; - internal UnspecifiedApiVersionEndpoint( ILogger logger, string[]? displayNames = default ) - : base( c => OnExecute( c, displayNames, logger ), Empty, Name ) { } - - private static Task OnExecute( HttpContext context, string[]? candidateEndpoints, ILogger logger ) + internal UnspecifiedApiVersionEndpoint( + ILogger logger, + ApiVersioningOptions options, + string[]? displayNames = default ) + : base( context => OnExecute( context, options, displayNames, logger ), Empty, Name ) { } + + private static Task OnExecute( + HttpContext context, + ApiVersioningOptions options, + string[]? candidateEndpoints, + ILogger logger ) { if ( candidateEndpoints == null || candidateEndpoints.Length == 0 ) { @@ -26,6 +33,8 @@ private static Task OnExecute( HttpContext context, string[]? candidateEndpoints context.Response.StatusCode = StatusCodes.Status400BadRequest; + EndpointProblem.TryReportApiVersions( context, options ); + if ( context.TryGetProblemDetailsService( out var problemDetails ) ) { return problemDetails.TryWriteAsync(