diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/Basic/given a versioned ApiController/when using a query string and split into two types.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/Basic/given a versioned ApiController/when using a query string and split into two types.cs index fab6d7d1..cffde314 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/Basic/given a versioned ApiController/when using a query string and split into two types.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/Basic/given a versioned ApiController/when using a query string and split into two types.cs @@ -34,7 +34,7 @@ public async Task then_get_should_return_200( string controller, string apiVersi } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -44,7 +44,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingConventions/given a versioned ApiController using conventions/when using a query string and split into two types.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingConventions/given a versioned ApiController using conventions/when using a query string and split into two types.cs index 08ecf18c..1080b60e 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingConventions/given a versioned ApiController using conventions/when using a query string and split into two types.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingConventions/given a versioned ApiController using conventions/when using a query string and split into two types.cs @@ -35,7 +35,7 @@ public async Task then_get_should_return_200( string controller, string apiVersi } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -45,7 +45,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingNamespace/given a versioned ApiController per namespace/when using a query string.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingNamespace/given a versioned ApiController per namespace/when using a query string.cs index 652b9347..06c84e76 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingNamespace/given a versioned ApiController per namespace/when using a query string.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingNamespace/given a versioned ApiController per namespace/when using a query string.cs @@ -32,7 +32,7 @@ public async Task then_get_should_return_200( Type controllerType, string apiVer } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -42,7 +42,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with Web API controllers/when people is any version.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with Web API controllers/when people is any version.cs index 9070597e..b354d6f6 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with Web API controllers/when people is any version.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with Web API controllers/when people is any version.cs @@ -9,7 +9,7 @@ namespace given_a_versioned_ODataController_mixed_with_Web_API_controllers; public class when_people_is_any_version : AdvancedAcceptanceTest { [Fact] - public async Task then_patch_should_return_404_for_an_unsupported_version() + public async Task then_patch_should_return_400_for_an_unsupported_version() { // arrange var person = new { lastName = "Me" }; @@ -19,7 +19,7 @@ public async Task then_patch_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs index f32b79c3..ad4f5364 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs @@ -28,7 +28,7 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -38,7 +38,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } @@ -73,7 +73,7 @@ public async Task then_patch_should_return_405_if_supported_in_any_version( stri } [Fact] - public async Task then_patch_should_return_404_for_an_unsupported_version() + public async Task then_patch_should_return_400_for_an_unsupported_version() { // arrange var person = new { id = 42, firstName = "John", lastName = "Doe", email = "john.doe@somewhere.com" }; @@ -83,7 +83,7 @@ public async Task then_patch_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs index 591fc75f..b73cde2c 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs @@ -24,7 +24,7 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -34,7 +34,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/ODataAcceptanceTest.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/ODataAcceptanceTest.cs index 32491a77..d69604f8 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/ODataAcceptanceTest.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/ODataAcceptanceTest.cs @@ -37,7 +37,7 @@ public async Task then_the_service_document_should_be_versionX2Dspecific( string } [Fact] - public async Task then_the_service_document_should_return_404_for_an_unsupported_version() + public async Task then_the_service_document_should_return_400_for_an_unsupported_version() { // arrange @@ -47,7 +47,7 @@ public async Task then_the_service_document_should_return_404_for_an_unsupported var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } @@ -83,7 +83,7 @@ public async Task then_X24metadata_should_be_versionX2Dspecific( string apiVersi } [Fact] - public async Task then_X24metadata_should_return_404_for_an_unsupported_version() + public async Task then_X24metadata_should_return_400_for_an_unsupported_version() { // arrange Client.DefaultRequestHeaders.Clear(); @@ -93,7 +93,7 @@ public async Task then_X24metadata_should_return_404_for_an_unsupported_version( var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs index 0cd5170d..46e15b8d 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs @@ -28,7 +28,7 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -38,7 +38,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } @@ -73,7 +73,7 @@ public async Task then_patch_should_return_405_if_supported_in_any_version( stri } [Fact] - public async Task then_patch_should_return_404_for_an_unsupported_version() + public async Task then_patch_should_return_400_for_an_unsupported_version() { // arrange var person = new { id = 42, firstName = "John", lastName = "Doe", email = "john.doe@somewhere.com" }; @@ -83,7 +83,7 @@ public async Task then_patch_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs index ba475388..4956d346 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs @@ -24,7 +24,7 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -34,7 +34,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dispatcher/HttpResponseExceptionFactory.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dispatcher/HttpResponseExceptionFactory.cs index 82a9761d..4afb0774 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dispatcher/HttpResponseExceptionFactory.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dispatcher/HttpResponseExceptionFactory.cs @@ -9,8 +9,6 @@ namespace Asp.Versioning.Dispatcher; using System.Web.Http.Tracing; using static System.Net.HttpStatusCode; -#pragma warning disable CA2000 // Dispose objects before losing scope - internal sealed class HttpResponseExceptionFactory { private const string Allow = nameof( Allow ); @@ -64,7 +62,8 @@ internal HttpResponseException NewUnmatchedException( } } - var versionsOnlyByMediaType = Options.ApiVersionReader.VersionsByMediaType( allowMultipleLocations: false ); + var options = Options; + var versionsOnlyByMediaType = options.ApiVersionReader.VersionsByMediaType( allowMultipleLocations: false ); if ( versionsOnlyByMediaType ) { @@ -75,9 +74,28 @@ internal HttpResponseException NewUnmatchedException( if ( couldMatch ) { properties ??= request.ApiVersionProperties(); - response = properties.RequestedApiVersion is ApiVersion apiVersion - ? CreateResponseForUnsupportedApiVersion( apiVersion, NotFound ) - : CreateNotFound( conventionRouteResult ); + + if ( properties.RequestedApiVersion is ApiVersion apiVersion ) + { + HttpStatusCode statusCode; + var matchedUrlSegment = !string.IsNullOrEmpty( properties.RouteParameter ); + + if ( matchedUrlSegment ) + { + statusCode = NotFound; + } + else + { + var versionsByUrlOnly = options.ApiVersionReader.VersionsByUrl( allowMultipleLocations: false ); + statusCode = versionsByUrlOnly ? NotFound : options.UnsupportedApiVersionStatusCode; + } + + response = CreateResponseForUnsupportedApiVersion( apiVersion, statusCode ); + } + else + { + response = CreateNotFound( conventionRouteResult ); + } } else { diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Dispatcher/ApiVersionControllerSelectorTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Dispatcher/ApiVersionControllerSelectorTest.cs index 3dc5df0d..25c3be0e 100644 --- a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Dispatcher/ApiVersionControllerSelectorTest.cs +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/Dispatcher/ApiVersionControllerSelectorTest.cs @@ -163,7 +163,7 @@ public void select_controller_should_return_correct_versionX2DneutralX2C_convent } [Fact] - public async Task select_controller_should_return_404_for_unmatchedX2C_attributeX2Dbased_controller_version() + public async Task select_controller_should_return_400_for_unmatchedX2C_attributeX2Dbased_controller_version() { // arrange var detail = "The HTTP resource that matches the request URI 'http://localhost/api/test' does not support the API version '42.0'."; @@ -190,13 +190,13 @@ public async Task select_controller_should_return_404_for_unmatchedX2C_attribute var content = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0, 3.0, 4.0" ); response.Headers.GetValues( "api-deprecated-versions" ).Single().Should().Be( "3.0-Alpha" ); content.Should().BeEquivalentTo( new ProblemDetails() { - Status = 404, + Status = 400, Title = "Unsupported API version", Type = ProblemDetailsDefaults.Unsupported.Type, Detail = detail, @@ -255,7 +255,7 @@ public async Task select_controller_should_return_400_for_attributeX2Dbased_cont } [Fact] - public async Task select_controller_should_return_404_for_unmatchedX2C_conventionX2Dbased_controller_version() + public async Task select_controller_should_return_400_for_unmatchedX2C_conventionX2Dbased_controller_version() { // arrange var detail = "The HTTP resource that matches the request URI 'http://localhost/api/test' does not support the API version '4.0'."; @@ -283,13 +283,13 @@ public async Task select_controller_should_return_404_for_unmatchedX2C_conventio var content = await response.Content.ReadAsProblemDetailsAsync(); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0, 3.0" ); response.Headers.GetValues( "api-deprecated-versions" ).Single().Should().Be( "1.8, 1.9" ); content.Should().BeEquivalentTo( new ProblemDetails() { - Status = 404, + Status = 400, Title = "Unsupported API version", Type = ProblemDetailsDefaults.Unsupported.Type, Detail = detail, @@ -413,7 +413,7 @@ public void select_controller_should_return_400_when_no_version_is_specified_and } [Fact] - public void select_controller_should_return_404_for_unmatched_action() + public void select_controller_should_return_400_for_unmatched_action() { // arrange var configuration = AttributeRoutingEnabledConfiguration; @@ -433,7 +433,7 @@ public void select_controller_should_return_404_for_unmatched_action() var response = selectController.Should().Throw().Subject.Single().Response; // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0, 2.0, 3.0, 4.0" ); response.Headers.GetValues( "api-deprecated-versions" ).Single().Should().Be( "3.0-Alpha" ); } diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using an endpoint.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using an endpoint.cs index 45329292..de479673 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using an endpoint.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when using an endpoint.cs @@ -11,7 +11,7 @@ namespace given_a_versioned_minimal_API; public class when_using_an_endpoint : AcceptanceTest { [Theory] - [InlineData( "api/order?api-version=0.9", NotFound )] + [InlineData( "api/order?api-version=0.9", BadRequest )] [InlineData( "api/order?api-version=1.0", OK )] [InlineData( "api/order?api-version=2.0", OK )] [InlineData( "api/order/42?api-version=0.9", OK )] 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 f692f92d..7c78efd4 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 @@ -87,7 +87,7 @@ public async Task then_delete_should_return_405( string apiVersion ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -96,7 +96,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var response = await GetAsync( "api/values?api-version=3.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingConventions/given a versioned Controller using conventions/when using a query string and split into two types.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingConventions/given a versioned Controller using conventions/when using a query string and split into two types.cs index 069d4c36..081fae3c 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingConventions/given a versioned Controller using conventions/when using a query string and split into two types.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingConventions/given a versioned Controller using conventions/when using a query string and split into two types.cs @@ -29,7 +29,7 @@ public async Task then_get_should_return_200( string controller, string apiVersi } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -38,7 +38,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var response = await GetAsync( "api/values?api-version=4.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingNamespace/given a versioned Controller per namespace/when using a query string.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingNamespace/given a versioned Controller per namespace/when using a query string.cs index 18669a23..7a1f29a4 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingNamespace/given a versioned Controller per namespace/when using a query string.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingNamespace/given a versioned Controller per namespace/when using a query string.cs @@ -34,7 +34,7 @@ public async Task then_get_should_return_200( Type controllerType, string apiVer } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -43,7 +43,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var response = await GetAsync( "api/agreements/42?api-version=4.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with base controllers/when people is any version.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with base controllers/when people is any version.cs index dc1ff8bb..82aced7b 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with base controllers/when people is any version.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Advanced/given a versioned ODataController mixed with base controllers/when people is any version.cs @@ -2,14 +2,13 @@ namespace given_a_versioned_ODataController_mixed_with_base_controllers; -using Asp.Versioning; using Asp.Versioning.OData.Advanced; using static System.Net.HttpStatusCode; public class when_people_is_any_version : AdvancedAcceptanceTest { [Fact] - public async Task then_patch_should_return_404_for_an_unsupported_version() + public async Task then_patch_should_return_400_for_an_unsupported_version() { // arrange var person = new { lastName = "Me" }; @@ -18,7 +17,7 @@ public async Task then_patch_should_return_404_for_an_unsupported_version() var response = await PatchAsync( $"api/people/42?api-version=4.0", person ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs index a42aeb03..3f51e4f7 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string and split into two types.cs @@ -28,7 +28,7 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -37,7 +37,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var response = await GetAsync( "api/people?api-version=4.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] @@ -84,7 +84,7 @@ public async Task then_delete_should_return_405_for_unmatched_action() } [Fact] - public async Task then_patch_should_return_404_for_an_unsupported_version() + public async Task then_patch_should_return_400_for_an_unsupported_version() { // arrange var person = new { id = 42, firstName = "John", lastName = "Doe", email = "john.doe@somewhere.com" }; @@ -93,7 +93,7 @@ public async Task then_patch_should_return_404_for_an_unsupported_version() var response = await PatchAsync( "api/people/42?api-version=4.0", person ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs index 94fa7576..79c317d7 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/Basic/given a versioned ODataController/when using a query string.cs @@ -4,6 +4,7 @@ namespace given_a_versioned_ODataController; using Asp.Versioning; using Asp.Versioning.OData.Basic; +using System.Net; using static System.Net.HttpStatusCode; public class when_using_a_query_string : BasicAcceptanceTest @@ -17,23 +18,29 @@ public async Task then_get_should_return_200( string requestUrl ) // act - var response = (await GetAsync( requestUrl )).EnsureSuccessStatusCode(); + var response = ( await GetAsync( requestUrl ) ).EnsureSuccessStatusCode(); // assert response.Headers.GetValues( "api-supported-versions" ).Single().Should().Be( "1.0" ); } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange - + // note: it's not clear why this is, but it appears to be a change + // in the routing system from netcoreapp3.1 to net6.0+ +#if NETCOREAPP3_1 + const HttpStatusCode StatusCode = NotFound; +#else + const HttpStatusCode StatusCode = BadRequest; +#endif // act var response = await GetAsync( "api/orders?api-version=2.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( StatusCode ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/ODataAcceptanceTest.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/ODataAcceptanceTest.cs index 3bec69f6..940703e6 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/ODataAcceptanceTest.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/ODataAcceptanceTest.cs @@ -37,7 +37,7 @@ public async Task then_the_service_document_should_be_versionX2Dspecific( string } [Fact] - public async Task then_the_service_document_should_return_404_for_an_unsupported_version() + public async Task then_the_service_document_should_return_400_for_an_unsupported_version() { // arrange @@ -46,7 +46,7 @@ public async Task then_the_service_document_should_return_404_for_an_unsupported var response = await GetAsync( "api?api-version=4.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] @@ -79,7 +79,7 @@ public async Task then_X24metadata_should_be_versionX2Dspecific( string apiVersi } [Fact] - public async Task then_X24metadata_should_return_404_for_an_unsupported_version() + public async Task then_X24metadata_should_return_400_for_an_unsupported_version() { // arrange @@ -88,7 +88,7 @@ public async Task then_X24metadata_should_return_404_for_an_unsupported_version( var response = await GetAsync( "api/$metadata?api-version=4.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } protected ODataAcceptanceTest( ODataFixture fixture ) : base( fixture ) { } diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs index 012ab31d..dca28575 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string and split into two types.cs @@ -28,7 +28,7 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange @@ -37,7 +37,7 @@ public async Task then_get_should_return_404_for_an_unsupported_version() var response = await GetAsync( "api/people?api-version=4.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] @@ -71,7 +71,7 @@ public async Task then_patch_should_return_400_if_supported_in_any_version( stri } [Fact] - public async Task then_patch_should_return_404_for_an_unsupported_version() + public async Task then_patch_should_return_400_for_an_unsupported_version() { // arrange var person = new { id = 42, firstName = "John", lastName = "Doe", email = "john.doe@somewhere.com" }; @@ -80,7 +80,7 @@ public async Task then_patch_should_return_404_for_an_unsupported_version() var response = await PatchAsync( "api/people/42?api-version=4.0", person ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( BadRequest ); } [Fact] diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs index 64dd1dea..e145ac04 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/OData/UsingConventions/given a versioned ODataController using conventions/when using a query string.cs @@ -4,6 +4,7 @@ namespace given_a_versioned_ODataController_using_conventions; using Asp.Versioning; using Asp.Versioning.OData.UsingConventions; +using System.Net; using static System.Net.HttpStatusCode; public class when_using_a_query_string : ConventionsAcceptanceTest @@ -24,16 +25,22 @@ public async Task then_get_should_return_200( string requestUrl ) } [Fact] - public async Task then_get_should_return_404_for_an_unsupported_version() + public async Task then_get_should_return_400_for_an_unsupported_version() { // arrange - + // note: it's not clear why this is, but it appears to be a change + // in the routing system from netcoreapp3.1 to net6.0+ +#if NETCOREAPP3_1 + const HttpStatusCode StatusCode = NotFound; +#else + const HttpStatusCode StatusCode = BadRequest; +#endif // act var response = await GetAsync( "api/orders?api-version=2.0" ); // assert - response.StatusCode.Should().Be( NotFound ); + response.StatusCode.Should().Be( StatusCode ); } [Fact] 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 47fdad17..46b93a13 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -93,7 +93,7 @@ public Task ApplyAsync( HttpContext httpContext, CandidateSet candidates ) if ( !matched && hasCandidates && !DifferByRouteConstraintsOnly( candidates ) ) { - var builder = new ClientErrorEndpointBuilder( feature, candidates, logger ); + var builder = new ClientErrorEndpointBuilder( feature, candidates, Options, logger ); httpContext.SetEndpoint( builder.Build() ); } @@ -108,19 +108,23 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList( capacity ); var source = ApiVersionSource; - var versionsByUrl = source.VersionsByUrl(); - var routePatterns = default( List ); + var supported = default( SortedSet ); + var deprecated = default( SortedSet ); + var routePatterns = default( RoutePattern[] ); for ( var i = 0; i < edges.Count; i++ ) { var edge = edges[i]; var state = (EdgeKey) edge.State; - var version = state.ApiVersion; + + if ( Options.ReportApiVersions ) + { + Collate( state.Metadata, ref supported, ref deprecated ); + } switch ( state.EndpointType ) { @@ -133,6 +137,9 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList 0 ) - { - routePatterns ??= new(); - routePatterns.AddRange( state.RoutePatterns ); - } - - destinations.Add( version, edge.Destination ); + // the route patterns provided to each edge is a + // singleton so any edge will do + routePatterns ??= state.RoutePatterns.ToArray(); + destinations.Add( state.ApiVersion, edge.Destination ); break; } } @@ -157,7 +161,8 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList) Array.Empty(), + NewPolicyFeature( supported, deprecated ), + routePatterns ?? Array.Empty(), apiVersionParser, source, Options ); @@ -174,8 +179,8 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints var capacity = endpoints.Count; var builder = new EdgeBuilder( capacity, ApiVersionSource, Options, logger ); var versions = new SortedSet(); - var neutralEndpoints = default( List ); - var versionedEndpoints = new (RouteEndpoint, ApiVersionModel)[capacity]; + var neutralEndpoints = default( List<(RouteEndpoint, ApiVersionMetadata)> ); + var versionedEndpoints = new (RouteEndpoint, ApiVersionModel, ApiVersionMetadata)[capacity]; var count = 0; for ( var i = 0; i < endpoints.Count; i++ ) @@ -190,14 +195,14 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints if ( model.IsApiVersionNeutral ) { - builder.Add( endpoint, ApiVersion.Neutral ); + builder.Add( endpoint, ApiVersion.Neutral, metadata ); neutralEndpoints ??= new(); - neutralEndpoints.Add( endpoint ); + neutralEndpoints.Add( (endpoint, metadata) ); } else { builder.Add( endpoint ); - versionedEndpoints[count++] = (endpoint, model); + versionedEndpoints[count++] = (endpoint, model, metadata); versions.AddRange( model.DeclaredApiVersions ); } } @@ -206,12 +211,12 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints { for ( var j = 0; j < count; j++ ) { - var (endpoint, model) = versionedEndpoints[j]; + var (endpoint, model, metadata) = versionedEndpoints[j]; var mappedWithImplementation = model.ImplementedApiVersions.Contains( version ); if ( mappedWithImplementation ) { - builder.Add( endpoint, version ); + builder.Add( endpoint, version, metadata ); } } @@ -223,7 +228,8 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints // add an edge for all known versions because version-neutral endpoints can map to any api version for ( var j = 0; j < neutralEndpoints.Count; j++ ) { - builder.Add( neutralEndpoints[j], version ); + var (endpoint, metadata) = neutralEndpoints[j]; + builder.Add( endpoint, version, metadata ); } } @@ -293,6 +299,77 @@ private static bool DifferByRouteConstraintsOnly( CandidateSet candidates ) return false; } + private static void Collate( + ApiVersionMetadata metadata, + ref SortedSet? supported, + ref SortedSet? deprecated ) + { + var model = metadata.Map( Implicit | Explicit ); + var versions = model.SupportedApiVersions; + + if ( versions.Count > 0 ) + { + supported ??= new(); + + for ( var j = 0; j < versions.Count; j++ ) + { + supported.Add( versions[j] ); + } + } + + versions = model.DeprecatedApiVersions; + + if ( versions.Count == 0 ) + { + return; + } + + deprecated ??= new(); + + for ( var j = 0; j < versions.Count; j++ ) + { + deprecated.Add( versions[j] ); + } + } + + private static ApiVersionPolicyFeature? NewPolicyFeature( + 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 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 ) + { + if ( deprecated == null ) + { + return default; + } + + model = new( Enumerable.Empty(), deprecated ); + } + else if ( deprecated == null ) + { + model = new( supported, Enumerable.Empty() ); + } + else + { + deprecated.ExceptWith( supported ); + model = new( supported, deprecated ); + } + + return new( new( model, model ) ); + } + private static (bool Matched, bool HasCandidates) MatchApiVersion( CandidateSet candidates, ApiVersion? apiVersion ) { var total = candidates.Count; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyFeature.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyFeature.cs new file mode 100644 index 00000000..d6ca9ed7 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyFeature.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Routing; + +internal sealed class ApiVersionPolicyFeature +{ + public ApiVersionPolicyFeature( ApiVersionMetadata metadata ) => Metadata = metadata; + + public ApiVersionMetadata Metadata { get; } +} \ 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 ed8db5bd..a7eb03da 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -15,6 +15,7 @@ internal sealed class ApiVersionPolicyJumpTable : PolicyJumpTable private readonly bool versionsByMediaTypeOnly; private readonly RouteDestination rejection; private readonly IReadOnlyDictionary destinations; + private readonly ApiVersionPolicyFeature? policyFeature; private readonly IReadOnlyList routePatterns; private readonly IApiVersionParser parser; private readonly ApiVersioningOptions options; @@ -22,6 +23,7 @@ internal sealed class ApiVersionPolicyJumpTable : PolicyJumpTable internal ApiVersionPolicyJumpTable( RouteDestination rejection, IReadOnlyDictionary destinations, + ApiVersionPolicyFeature? policyFeature, IReadOnlyList routePatterns, IApiVersionParser parser, IApiVersionParameterSource source, @@ -29,6 +31,7 @@ internal ApiVersionPolicyJumpTable( { this.rejection = rejection; this.destinations = destinations; + this.policyFeature = policyFeature; this.routePatterns = routePatterns; this.parser = parser; this.options = options; @@ -42,6 +45,7 @@ public override int GetDestination( HttpContext httpContext ) var request = httpContext.Request; var feature = httpContext.ApiVersioningFeature(); var apiVersions = new List( capacity: feature.RawRequestedApiVersions.Count + 1 ); + var addedFromUrl = false; apiVersions.AddRange( feature.RawRequestedApiVersions ); @@ -50,6 +54,7 @@ public override int GetDestination( HttpContext httpContext ) DoesNotContainApiVersion( apiVersions, rawApiVersion ) ) { apiVersions.Add( rawApiVersion ); + addedFromUrl = apiVersions.Count == apiVersions.Capacity; } int destination; @@ -83,6 +88,11 @@ public override int GetDestination( HttpContext httpContext ) if ( versionsByUrl ) { feature.RawRequestedApiVersion = rawApiVersion; + + if ( versionsByUrlOnly ) + { + return rejection.Exit; // 404 + } } return rejection.Malformed; // 400 @@ -93,6 +103,8 @@ public override int GetDestination( HttpContext httpContext ) return destination; } + httpContext.Features.Set( policyFeature ); + if ( versionsByMediaTypeOnly ) { if ( request.Headers.ContainsKey( HeaderNames.ContentType ) ) @@ -103,11 +115,11 @@ public override int GetDestination( HttpContext httpContext ) return rejection.NotAcceptable; // 406 } - return rejection.Exit; // 404 + return addedFromUrl + /* 404 */ ? rejection.Exit + /* 400 */ : rejection.Unsupported; } - var addedFromUrl = apiVersions.Count == apiVersions.Capacity; - if ( addedFromUrl ) { feature.RawRequestedApiVersions = apiVersions; 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 cef85a64..8077e8f9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ClientErrorEndpointBuilder.cs @@ -6,21 +6,23 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; using Microsoft.Extensions.Logging; -using System.Text; internal sealed class ClientErrorEndpointBuilder { private readonly IApiVersioningFeature feature; private readonly CandidateSet candidates; + private readonly ApiVersioningOptions options; private readonly ILogger logger; public ClientErrorEndpointBuilder( IApiVersioningFeature feature, CandidateSet candidates, + ApiVersioningOptions options, ILogger logger ) { this.feature = feature; this.candidates = candidates; + this.options = options; this.logger = logger; } @@ -31,7 +33,7 @@ public Endpoint Build() return new UnspecifiedApiVersionEndpoint( logger, GetDisplayNames() ); } - return new UnsupportedApiVersionEndpoint(); + return new UnsupportedApiVersionEndpoint( 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 7663a809..010210f7 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -10,13 +10,15 @@ namespace Asp.Versioning.Routing; internal sealed class EdgeBuilder { + private const int RejectionEndpointCapacity = NumberOfRejectionEndpoints + 1; + internal const int NumberOfRejectionEndpoints = 6; private readonly bool versionsByUrl; - private readonly bool unspecifiedNotAllowed; + private readonly bool unspecifiedAllowed; private readonly string constraintName; private readonly HashSet keys; private readonly Dictionary> edges; + private readonly HashSet routePatterns = new( new RoutePatternComparer() ); private EdgeKey assumeDefault = EdgeKey.AssumeDefault; - private HashSet? routePatterns; public EdgeBuilder( int capacity, @@ -25,35 +27,41 @@ public EdgeBuilder( ILogger logger ) { versionsByUrl = source.VersionsByUrl(); - unspecifiedNotAllowed = !options.AssumeDefaultVersionWhenUnspecified; + unspecifiedAllowed = options.AssumeDefaultVersionWhenUnspecified; constraintName = options.RouteConstraintName; keys = new( capacity + 1 ); - edges = new( capacity + 6 ) + 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.UnsupportedMediaType] = new( capacity: 1 ) { new UnsupportedMediaTypeEndpoint() }, - [EdgeKey.NotAcceptable] = new( capacity: 1 ) { new NotAcceptableEndpoint() }, + [EdgeKey.Unsupported] = new( capacity: 1 ) { new UnsupportedApiVersionEndpoint( options ) }, + [EdgeKey.UnsupportedMediaType] = new( capacity: 1 ) { new UnsupportedMediaTypeEndpoint( options ) }, + [EdgeKey.NotAcceptable] = new( capacity: 1 ) { new NotAcceptableEndpoint( options ) }, }; } - public IReadOnlyList Build() => - edges.Select( edge => new PolicyNodeEdge( edge.Key, edge.Value ) ).ToArray(); + public IReadOnlyList Build() + { + routePatterns.TrimExcess(); + return edges.Select( edge => new PolicyNodeEdge( edge.Key, edge.Value ) ).ToArray(); + } public void Add( RouteEndpoint endpoint ) { - if ( unspecifiedNotAllowed ) + if ( unspecifiedAllowed ) { - return; + Add( ref assumeDefault, endpoint ); } - - Add( ref assumeDefault, endpoint ); } - public void Add( RouteEndpoint endpoint, ApiVersion apiVersion ) + public void Add( RouteEndpoint endpoint, ApiVersion apiVersion, ApiVersionMetadata metadata ) { - var key = new EdgeKey( apiVersion ); + // use a singleton of all route patterns that version by url segment. this + // is needed to extract the value for selecting a destination in the jump + // table. any matching template will do and every edge should have the + // same list known through the application, which may be zero + var key = new EdgeKey( apiVersion, metadata, routePatterns ); Add( ref key, endpoint ); } @@ -73,13 +81,7 @@ private void Add( ref EdgeKey key, RouteEndpoint endpoint ) if ( needsRoutePattern ) { - routePatterns ??= new( new RoutePatternComparer() ); - needsRoutePattern &= routePatterns.Add( routePattern ); - - if ( needsRoutePattern ) - { - key.RoutePatterns.Add( routePattern ); - } + routePatterns.Add( routePattern ); } if ( !edges.TryGetValue( key, out var endpoints ) ) 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 bc8208a6..bdc5c835 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs @@ -9,43 +9,60 @@ namespace Asp.Versioning.Routing; internal readonly struct EdgeKey : IEquatable { public readonly ApiVersion ApiVersion; - public readonly List RoutePatterns; + public readonly ApiVersionMetadata Metadata; + public readonly HashSet RoutePatterns; public readonly EndpointType EndpointType; - private EdgeKey( EndpointType endpointType, List routePatterns ) + private EdgeKey( EndpointType endpointType, HashSet routePatterns ) { ApiVersion = ApiVersion.Default; + Metadata = ApiVersionMetadata.Empty; RoutePatterns = routePatterns; EndpointType = endpointType; } - internal EdgeKey( ApiVersion apiVersion ) + internal EdgeKey( + ApiVersion apiVersion, + ApiVersionMetadata metadata, + HashSet routePatterns ) { ApiVersion = apiVersion; - RoutePatterns = new(); + Metadata = metadata; + RoutePatterns = routePatterns; EndpointType = UserDefined; } - internal static EdgeKey Ambiguous => new( EndpointType.Ambiguous, new( capacity: 0 ) ); + internal static EdgeKey Ambiguous => new( EndpointType.Ambiguous, Set.Empty ); + + internal static EdgeKey Malformed => new( EndpointType.Malformed, Set.Empty ); - internal static EdgeKey Malformed => new( EndpointType.Malformed, new( capacity: 0 ) ); + internal static EdgeKey Unspecified => new( EndpointType.Unspecified, Set.Empty ); - internal static EdgeKey Unspecified => new( EndpointType.Unspecified, new( capacity: 0 ) ); + internal static EdgeKey Unsupported => new( EndpointType.Unsupported, Set.Empty ); - internal static EdgeKey UnsupportedMediaType => new( EndpointType.UnsupportedMediaType, new( capacity: 0 ) ); + internal static EdgeKey UnsupportedMediaType => new( EndpointType.UnsupportedMediaType, Set.Empty ); - internal static EdgeKey NotAcceptable => new( EndpointType.NotAcceptable, new( capacity: 0 ) ); + internal static EdgeKey NotAcceptable => new( EndpointType.NotAcceptable, Set.Empty ); - internal static EdgeKey AssumeDefault => new( EndpointType.AssumeDefault, new() ); + internal static EdgeKey AssumeDefault => new( EndpointType.AssumeDefault, new( new RoutePatternComparer() ) ); public bool Equals( [AllowNull] EdgeKey other ) => GetHashCode() == other.GetHashCode(); public override bool Equals( object? obj ) => obj is EdgeKey other && Equals( other ); - public override int GetHashCode() => - EndpointType == UserDefined ? - HashCode.Combine( ApiVersion, EndpointType ) : - EndpointType.GetHashCode(); + public override int GetHashCode() + { + var result = default( HashCode ); + + result.Add( EndpointType ); + + if ( EndpointType == UserDefined ) + { + result.Add( ApiVersion ); + } + + return result.ToHashCode(); + } public override string ToString() { @@ -66,4 +83,9 @@ public override string ToString() return "VER: " + value; } + + private static class Set + { + public static readonly HashSet Empty = new( capacity: 0 ); + } } \ No newline at end of file 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 ea7b4f10..8ad75c35 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs @@ -9,7 +9,10 @@ namespace Asp.Versioning.Routing; internal static class EndpointProblem { - internal static Task UnsupportedApiVersion( HttpContext context, int statusCode ) + internal static Task UnsupportedApiVersion( + HttpContext context, + ApiVersioningOptions options, + int statusCode ) { var services = context.RequestServices; var factory = services.GetRequiredService(); @@ -30,9 +33,24 @@ internal static Task UnsupportedApiVersion( HttpContext context, int statusCode context.Response.StatusCode = statusCode; + if ( options.ReportApiVersions && + context.Features.Get() is ApiVersionPolicyFeature feature ) + { + var reporter = services.GetRequiredService(); + var model = feature.Metadata.Map( reporter.Mapping ); + context.Response.OnStarting( ReportApiVersions, (reporter, context.Response, model) ); + } + return context.Response.WriteAsJsonAsync( problem, options: default, contentType: ProblemDetailsDefaults.MediaType.Json ); } + + private static Task ReportApiVersions( object state ) + { + var (reporter, response, model) = ((IReportApiVersions, HttpResponse, ApiVersionModel)) state; + reporter.Report( response, model ); + return Task.CompletedTask; + } } \ No newline at end of file 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 f871400a..73f0c1bd 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs @@ -11,4 +11,5 @@ internal enum EndpointType UnsupportedMediaType, AssumeDefault, NotAcceptable, + Unsupported, } \ No newline at end of file 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 f6b4cdbe..51731b1e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs @@ -9,8 +9,12 @@ internal sealed class NotAcceptableEndpoint : Endpoint { private const string Name = "406 HTTP Not Acceptable"; - internal NotAcceptableEndpoint() : base( OnExecute, Empty, Name ) { } - - private static Task OnExecute( HttpContext context ) => - EndpointProblem.UnsupportedApiVersion( context, StatusCodes.Status406NotAcceptable ); + internal NotAcceptableEndpoint( ApiVersioningOptions options ) + : base( + context => EndpointProblem.UnsupportedApiVersion( + context, + options, + StatusCodes.Status406NotAcceptable ), + Empty, + 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 bda9c9d2..39dcbb64 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs @@ -8,6 +8,7 @@ internal struct RouteDestination public int Malformed; public int Ambiguous; public int Unspecified; + public int Unsupported; public int UnsupportedMediaType; public int AssumeDefault; public int NotAcceptable; @@ -18,6 +19,7 @@ public RouteDestination( int exit ) Malformed = exit; Ambiguous = exit; Unspecified = exit; + Unsupported = exit; UnsupportedMediaType = exit; AssumeDefault = exit; NotAcceptable = exit; 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 8a1661ab..1cd83a42 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs @@ -7,10 +7,15 @@ namespace Asp.Versioning.Routing; internal sealed class UnsupportedApiVersionEndpoint : Endpoint { - private const string Name = "400 Unsupported API Version"; + private const string Name = " Unsupported API Version"; - internal UnsupportedApiVersionEndpoint() : base( OnExecute, Empty, Name ) { } - - private static Task OnExecute( HttpContext context ) => - EndpointProblem.UnsupportedApiVersion( context, StatusCodes.Status400BadRequest ); + internal UnsupportedApiVersionEndpoint( ApiVersioningOptions options ) + : base( + context => EndpointProblem.UnsupportedApiVersion( + context, + options, + options.UnsupportedApiVersionStatusCode ), + Empty, + 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 b164bf20..1e7492e6 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs @@ -9,8 +9,12 @@ internal sealed class UnsupportedMediaTypeEndpoint : Endpoint { private const string Name = "415 HTTP Unsupported Media Type"; - internal UnsupportedMediaTypeEndpoint() : base( OnExecute, Empty, Name ) { } - - private static Task OnExecute( HttpContext context ) => - EndpointProblem.UnsupportedApiVersion( context, StatusCodes.Status415UnsupportedMediaType ); + internal UnsupportedMediaTypeEndpoint( ApiVersioningOptions options ) + : base( + context => EndpointProblem.UnsupportedApiVersion( + context, + options, + StatusCodes.Status415UnsupportedMediaType ), + Empty, + Name ) { } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs index 301385cd..a42ee32c 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs @@ -8,6 +8,7 @@ namespace Asp.Versioning; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; public class ReportApiVersionsAttributeTest { @@ -85,7 +86,10 @@ private static ActionExecutingContext CreateContext( versioningFeature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1.0 ) ); features.Set( endpointFeature.Object ); features.Set( versioningFeature.Object ); - serviceProvider.Setup( sp => sp.GetService( typeof( IReportApiVersions ) ) ).Returns( new DefaultApiVersionReporter() ); + serviceProvider.Setup( sp => sp.GetService( typeof( IReportApiVersions ) ) ) + .Returns( new DefaultApiVersionReporter() ); + serviceProvider.Setup( sp => sp.GetService( typeof( ISunsetPolicyManager ) ) ) + .Returns( new SunsetPolicyManager( Options.Create( new ApiVersioningOptions() ) ) ); response.SetupGet( r => r.Headers ).Returns( headers ); response.SetupGet( r => r.HttpContext ).Returns( () => httpContext.Object ); response.Setup( r => r.OnStarting( It.IsAny>(), It.IsAny() ) )