From 7d25751530c1ca6b16aff944fc125e17b94362d7 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 3 Nov 2022 16:26:35 -0700 Subject: [PATCH 01/15] Handle multiple selector models. Fixes #896 --- .../VersionedApiDescriptionProvider.cs | 8 +++++--- .../ApplicationModels/ModelExtensions.cs | 13 ++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs index bce9e94c..94163f4d 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs @@ -94,10 +94,13 @@ protected virtual bool ShouldExploreAction( ActionDescriptor actionDescriptor, A protected virtual void PopulateApiVersionParameters( ApiDescription apiDescription, ApiVersion apiVersion ) { var parameterSource = Options.ApiVersionParameterSource; - var context = new ApiVersionParameterDescriptionContext( apiDescription, apiVersion, ModelMetadata, Options ); + var context = new ApiVersionParameterDescriptionContext( apiDescription, apiVersion, ModelMetadata, Options ) + { + ConstraintResolver = constraintResolver, + }; - context.ConstraintResolver = constraintResolver; parameterSource.AddParameters( context ); + apiDescription.TryUpdateRelativePathAndRemoveApiVersionParameter( Options ); } /// @@ -159,7 +162,6 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) groupResult.SetApiVersion( version ); PopulateApiVersionParameters( groupResult, version ); - groupResult.TryUpdateRelativePathAndRemoveApiVersionParameter( Options ); groupResults.Add( groupResult ); } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApplicationModels/ModelExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApplicationModels/ModelExtensions.cs index 2fdbc7d3..043435f8 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApplicationModels/ModelExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApplicationModels/ModelExtensions.cs @@ -37,17 +37,16 @@ public static ApiVersionModel GetApiVersionModel( this ControllerModel controlle internal static void AddEndpointMetadata( this ActionModel action, object metadata ) { - SelectorModel selector; + var selectors = action.Selectors; - if ( action.Selectors.Count == 0 ) + if ( selectors.Count == 0 ) { - action.Selectors.Add( selector = new() ); + selectors.Add( new() ); } - else + + for ( var i = 0; i < selectors.Count; i++ ) { - selector = action.Selectors[0]; + selectors[i].EndpointMetadata.Add( metadata ); } - - selector.EndpointMetadata.Add( metadata ); } } \ No newline at end of file From 3bd74027fa565027a9c068a74ee9276d2626506c Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 3 Nov 2022 16:48:10 -0700 Subject: [PATCH 02/15] Handle missing ApiVersionMetadata. Fixes #891 --- .../ODataApiDescriptionProvider.cs | 16 +++++- ...ODataMultiModelApplicationModelProvider.cs | 57 +++++++++++-------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs index d2c4ed0f..3feb6754 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs @@ -188,12 +188,24 @@ private static bool TryMatchModelVersion( IReadOnlyList items, [NotNullWhen( true )] out IODataRoutingMetadata? metadata ) { - var apiVersion = description.GetApiVersion()!; + if ( description.GetApiVersion() is not ApiVersion apiVersion ) + { + // this should only happen if an odata endpoint is registered outside of api versioning: + // + // builder.Services.AddControllers().AddOData(options => options.AddRouteComponents(new EdmModel())); + // + // instead of: + // + // builder.Services.AddControllers().AddOData(); + // builder.Services.AddApiVersioning().AddOData(options => options.AddRouteComponents()); + metadata = default; + return false; + } for ( var i = 0; i < items.Count; i++ ) { var item = items[i]; - var otherApiVersion = item.Model.GetAnnotationValue( item.Model ).ApiVersion; + var otherApiVersion = item.Model.GetAnnotationValue( item.Model )?.ApiVersion; if ( apiVersion.Equals( otherApiVersion ) ) { diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs index ce2d828b..c8fc3fe0 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs @@ -4,6 +4,7 @@ namespace Asp.Versioning.OData; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.OData; using Microsoft.AspNetCore.OData.Routing.Conventions; @@ -189,41 +190,47 @@ private static void CopyApiVersionEndpointMetadata( IList contr { var selectors = actions[j].Selectors; - if ( selectors.Count < 2 ) + if ( selectors.Count > 1 && FindMetadata( selectors ) is ApiVersionMetadata metadata ) { - continue; + NormalizeMetadata( selectors, metadata ); } + } + } + } - var metadata = selectors[0].EndpointMetadata.OfType().FirstOrDefault(); + private static ApiVersionMetadata? FindMetadata( IList selectors ) + { + for ( var i = 0; i < selectors.Count; i++ ) + { + var endpointMetadata = selectors[i].EndpointMetadata; - if ( metadata is null ) + for ( var j = 0; j < endpointMetadata.Count; j++ ) + { + if ( endpointMetadata[j] is ApiVersionMetadata metadata ) { - continue; + return metadata; } + } + } - for ( var k = 1; k < selectors.Count; k++ ) + return default; + } + + private static void NormalizeMetadata( IList selectors, ApiVersionMetadata metadata ) + { + for ( var i = 0; i < selectors.Count; i++ ) + { + var endpointMetadata = selectors[i].EndpointMetadata; + + for ( var j = endpointMetadata.Count - 1; j >= 0; j-- ) + { + if ( endpointMetadata[j] is ApiVersionMetadata ) { - var endpointMetadata = selectors[k].EndpointMetadata; - var found = false; - - for ( var l = 0; l < endpointMetadata.Count; l++ ) - { - if ( endpointMetadata[l] is not ApiVersionMetadata ) - { - continue; - } - - endpointMetadata[l] = metadata; - found = true; - break; - } - - if ( !found ) - { - endpointMetadata.Add( metadata ); - } + endpointMetadata.RemoveAt( j ); } } + + endpointMetadata.Insert( 0, metadata ); } } } \ No newline at end of file From 4fec78a486953e8d067938711996b9cc35022f81 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 3 Nov 2022 20:18:40 -0700 Subject: [PATCH 03/15] Support 406 and 415 with ProblemDetails. Resolves #886 --- .../Controllers/Values2Controller.cs | 14 ++++++- .../Controllers/ValuesController.cs | 10 ++++- .../when using media type negotiation.cs | 19 +++++++++- .../HttpResponseExceptionFactory.cs | 6 ++- .../Controllers/Values2Controller.cs | 12 +++++- .../Controllers/ValuesController.cs | 7 +++- .../when using media type negotiation.cs | 22 ++++++++++- .../Routing/ApiVersionMatcherPolicy.cs | 3 ++ .../Routing/ApiVersionPolicyJumpTable.cs | 18 +++++++-- .../Routing/EdgeBuilder.cs | 3 +- .../Asp.Versioning.Http/Routing/EdgeKey.cs | 2 + .../Routing/EndpointProblem.cs | 38 +++++++++++++++++++ .../Routing/EndpointType.cs | 1 + .../Routing/NotAcceptableEndpoint.cs | 16 ++++++++ .../Routing/RouteDestination.cs | 2 + .../Routing/UnsupportedApiVersionEndpoint.cs | 31 +-------------- .../Routing/UnsupportedMediaTypeEndpoint.cs | 7 +--- 17 files changed, 161 insertions(+), 50 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/Values2Controller.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/Values2Controller.cs index 295a8729..0d13c326 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/Values2Controller.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/Values2Controller.cs @@ -2,11 +2,21 @@ namespace Asp.Versioning.Http.UsingMediaType.Controllers; +using Newtonsoft.Json.Linq; using System.Web.Http; [ApiVersion( "2.0" )] -[Route( "api/values" )] +[RoutePrefix( "api/values" )] public class Values2Controller : ApiController { - public IHttpActionResult Get() => Ok( new { controller = GetType().Name, version = Request.GetRequestedApiVersion().ToString() } ); + [Route] + public IHttpActionResult Get() => + Ok( new { controller = GetType().Name, version = Request.GetRequestedApiVersion().ToString() } ); + + [Route( "{id}", Name = "GetByIdV2" )] + public IHttpActionResult Get( string id ) => + Ok( new { controller = GetType().Name, Id = id, version = Request.GetRequestedApiVersion().ToString() } ); + + public IHttpActionResult Post( [FromBody] JToken json ) => + CreatedAtRoute( "GetByIdV2", new { id = "42" }, json ); } \ No newline at end of file diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/ValuesController.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/ValuesController.cs index 7e0a356f..785904d7 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/ValuesController.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/Controllers/ValuesController.cs @@ -5,8 +5,14 @@ namespace Asp.Versioning.Http.UsingMediaType.Controllers; using System.Web.Http; [ApiVersion( "1.0" )] -[Route( "api/values" )] +[RoutePrefix( "api/values" )] public class ValuesController : ApiController { - public IHttpActionResult Get() => Ok( new { controller = GetType().Name, version = Request.GetRequestedApiVersion().ToString() } ); + [Route] + public IHttpActionResult Get() => + Ok( new { controller = GetType().Name, version = Request.GetRequestedApiVersion().ToString() } ); + + [Route( "{id}" )] + public IHttpActionResult Get( string id ) => + Ok( new { controller = GetType().Name, Id = id, version = Request.GetRequestedApiVersion().ToString() } ); } \ No newline at end of file diff --git a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/given a versioned ApiController/when using media type negotiation.cs b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/given a versioned ApiController/when using media type negotiation.cs index 65151494..7cbe560c 100644 --- a/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/given a versioned ApiController/when using media type negotiation.cs +++ b/src/AspNet/Acceptance/Asp.Versioning.WebApi.Acceptance.Tests/Http/UsingMediaType/given a versioned ApiController/when using media type negotiation.cs @@ -37,7 +37,7 @@ public async Task then_get_should_return_200( string controller, string apiVersi } [Fact] - public async Task then_get_should_return_415_for_an_unsupported_version() + public async Task then_get_should_return_406_for_an_unsupported_version() { // arrange using var request = new HttpRequestMessage( Get, "api/values" ) @@ -49,6 +49,23 @@ public async Task then_get_should_return_415_for_an_unsupported_version() var response = await Client.SendAsync( request ); var problem = await response.Content.ReadAsProblemDetailsAsync(); + // assert + response.StatusCode.Should().Be( NotAcceptable ); + problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); + } + + [Fact] + public async Task then_post_should_return_415_for_an_unsupported_version() + { + // arrange + var entity = new { text = "Test" }; + var mediaType = Parse( "application/json;v=3.0" ); + using var content = new ObjectContent( entity.GetType(), entity, new JsonMediaTypeFormatter(), mediaType ); + + // act + var response = await Client.PostAsync( "api/values", content ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); + // assert response.StatusCode.Should().Be( UnsupportedMediaType ); 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 9438f419..82a9761d 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dispatcher/HttpResponseExceptionFactory.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dispatcher/HttpResponseExceptionFactory.cs @@ -210,15 +210,17 @@ private HttpResponseMessage CreateNotFound( ControllerSelectionResult convention private HttpResponseMessage CreateUnsupportedMediaType() { + var content = request.Content; + var statusCode = content != null && content.Headers.ContentType != null ? UnsupportedMediaType : NotAcceptable; var version = request.GetRequestedApiVersion()?.ToString() ?? "(null)"; var detail = string.Format( CultureInfo.CurrentCulture, SR.VersionedMediaTypeNotSupported, version ); TraceWriter.Info( request, ControllerSelectorCategory, detail ); var (type, title) = ProblemDetailsDefaults.Unsupported; - var problem = ProblemDetails.CreateProblemDetails( request, (int) UnsupportedMediaType, title, type, detail ); + var problem = ProblemDetails.CreateProblemDetails( request, (int) statusCode, title, type, detail ); var (mediaType, formatter) = request.GetProblemDetailsResponseType(); - return request.CreateResponse( UnsupportedMediaType, problem, formatter, mediaType ); + return request.CreateResponse( statusCode, problem, formatter, mediaType ); } } \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/Values2Controller.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/Values2Controller.cs index bea57e1c..f3655eb1 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/Values2Controller.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/Values2Controller.cs @@ -13,9 +13,17 @@ namespace Asp.Versioning.Mvc.UsingMediaType.Controllers; public class Values2Controller : ControllerBase { [HttpGet] - public IActionResult Get( ApiVersion version ) => Ok( new { Controller = nameof( Values2Controller ), Version = version.ToString() } ); + public IActionResult Get( ApiVersion version ) => + Ok( new { Controller = nameof( Values2Controller ), Version = version.ToString() } ); + + [HttpGet( "{id}" )] + public IActionResult Get( string id, ApiVersion version ) => + Ok( new { Controller = nameof( Values2Controller ), Id = id, Version = version.ToString() } ); + + [HttpPost] + public IActionResult Post( JsonElement json ) => CreatedAtAction( nameof( Get ), new { id = "42" }, json ); [HttpPatch( "{id}" )] [Consumes( "application/merge-patch+json" )] - public IActionResult MergePatch( JsonElement json ) => NoContent(); + public IActionResult MergePatch( string id, JsonElement json ) => NoContent(); } \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/ValuesController.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/ValuesController.cs index ae15324b..989c96c9 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/ValuesController.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/Controllers/ValuesController.cs @@ -11,5 +11,10 @@ namespace Asp.Versioning.Mvc.UsingMediaType.Controllers; public class ValuesController : ControllerBase { [HttpGet] - public IActionResult Get() => Ok( new { Controller = nameof( ValuesController ), Version = HttpContext.GetRequestedApiVersion().ToString() } ); + public IActionResult Get() => + Ok( new { Controller = nameof( ValuesController ), Version = HttpContext.GetRequestedApiVersion().ToString() } ); + + [HttpGet( "{id}" )] + public IActionResult Get( string id ) => + Ok( new { Controller = nameof( ValuesController ), Id = id, Version = HttpContext.GetRequestedApiVersion().ToString() } ); } \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/given a versioned Controller/when using media type negotiation.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/given a versioned Controller/when using media type negotiation.cs index 5d6bf661..87413f89 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/given a versioned Controller/when using media type negotiation.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingMediaType/given a versioned Controller/when using media type negotiation.cs @@ -38,7 +38,7 @@ public async Task then_get_should_return_200( string controller, string apiVersi } [Fact] - public async Task then_get_should_return_415_for_an_unsupported_version() + public async Task then_get_should_return_406_for_an_unsupported_version() { // arrange using var request = new HttpRequestMessage( Get, "api/values" ) @@ -48,9 +48,29 @@ public async Task then_get_should_return_415_for_an_unsupported_version() // act var response = await Client.SendAsync( request ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); + + // assert + response.StatusCode.Should().Be( NotAcceptable ); + problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); + } + + [Fact] + public async Task then_post_should_return_415_for_an_unsupported_version() + { + // arrange + using var request = new HttpRequestMessage( Post, "api/values" ) + { + Content = JsonContent.Create( new { test = true }, Parse( "application/json;v=3.0" ) ), + }; + + // act + var response = await Client.SendAsync( request ); + var problem = await response.Content.ReadAsProblemDetailsAsync(); // assert response.StatusCode.Should().Be( UnsupportedMediaType ); + problem.Type.Should().Be( ProblemDetailsDefaults.Unsupported.Type ); } [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 ee0ad83f..47fdad17 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -139,6 +139,9 @@ public PolicyJumpTable BuildJumpTable( int exitDestination, IReadOnlyList 0 ) { 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 3fe7b6ed..950c6cd8 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -5,6 +5,7 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing.Matching; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Net.Http.Headers; using System.Runtime.CompilerServices; internal sealed class ApiVersionPolicyJumpTable : PolicyJumpTable @@ -36,13 +37,14 @@ internal ApiVersionPolicyJumpTable( public override int GetDestination( HttpContext httpContext ) { + var request = httpContext.Request; var feature = httpContext.ApiVersioningFeature(); var apiVersions = new List( capacity: feature.RawRequestedApiVersions.Count + 1 ); apiVersions.AddRange( feature.RawRequestedApiVersions ); if ( versionsByUrl && - TryGetApiVersionFromPath( httpContext.Request, out var rawApiVersion ) && + TryGetApiVersionFromPath( request, out var rawApiVersion ) && DoesNotContainApiVersion( apiVersions, rawApiVersion ) ) { apiVersions.Add( rawApiVersion ); @@ -86,9 +88,17 @@ public override int GetDestination( HttpContext httpContext ) return destination; } - return versionsByMediaTypeOnly - ? rejection.UnsupportedMediaType // 415 - : rejection.Exit; // 404 + if ( versionsByMediaTypeOnly ) + { + if ( request.Headers.ContainsKey( HeaderNames.ContentType ) ) + { + return rejection.UnsupportedMediaType; // 415 + } + + return rejection.NotAcceptable; // 406 + } + + return rejection.Exit; // 404 } var addedFromUrl = apiVersions.Count == apiVersions.Capacity; 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 464645e6..7663a809 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeBuilder.cs @@ -28,12 +28,13 @@ public EdgeBuilder( unspecifiedNotAllowed = !options.AssumeDefaultVersionWhenUnspecified; constraintName = options.RouteConstraintName; keys = new( capacity + 1 ); - edges = new( capacity + 5 ) + edges = new( capacity + 6 ) { [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() }, }; } 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 8406b4a5..bc8208a6 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EdgeKey.cs @@ -34,6 +34,8 @@ internal EdgeKey( ApiVersion apiVersion ) internal static EdgeKey UnsupportedMediaType => new( EndpointType.UnsupportedMediaType, new( capacity: 0 ) ); + internal static EdgeKey NotAcceptable => new( EndpointType.NotAcceptable, new( capacity: 0 ) ); + internal static EdgeKey AssumeDefault => new( EndpointType.AssumeDefault, new() ); public bool Equals( [AllowNull] EdgeKey other ) => GetHashCode() == other.GetHashCode(); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs new file mode 100644 index 00000000..ea7b4f10 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointProblem.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Routing; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.DependencyInjection; +using System.Globalization; + +internal static class EndpointProblem +{ + internal static Task UnsupportedApiVersion( HttpContext context, int statusCode ) + { + var services = context.RequestServices; + var factory = services.GetRequiredService(); + var url = new Uri( context.Request.GetDisplayUrl() ).SafeFullPath(); + var apiVersion = context.ApiVersioningFeature().RawRequestedApiVersion; + var (type, title) = ProblemDetailsDefaults.Unsupported; + var detail = string.Format( + CultureInfo.CurrentCulture, + SR.VersionedResourceNotSupported, + url, + apiVersion ); + var problem = factory.CreateProblemDetails( + context.Request, + statusCode, + title, + type, + detail ); + + context.Response.StatusCode = statusCode; + + return context.Response.WriteAsJsonAsync( + problem, + options: default, + contentType: ProblemDetailsDefaults.MediaType.Json ); + } +} \ 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 80a85c27..f871400a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/EndpointType.cs @@ -10,4 +10,5 @@ internal enum EndpointType Unspecified, UnsupportedMediaType, AssumeDefault, + NotAcceptable, } \ 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 new file mode 100644 index 00000000..f6b4cdbe --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/NotAcceptableEndpoint.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Routing; + +using Microsoft.AspNetCore.Http; +using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; + +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 ); +} \ 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 d78cc044..bda9c9d2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/RouteDestination.cs @@ -10,6 +10,7 @@ internal struct RouteDestination public int Unspecified; public int UnsupportedMediaType; public int AssumeDefault; + public int NotAcceptable; public RouteDestination( int exit ) { @@ -19,5 +20,6 @@ public RouteDestination( int exit ) Unspecified = exit; UnsupportedMediaType = exit; AssumeDefault = exit; + NotAcceptable = exit; } } \ No newline at end of file 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 f8921693..8a1661ab 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedApiVersionEndpoint.cs @@ -3,9 +3,6 @@ namespace Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.DependencyInjection; -using System.Globalization; using static Microsoft.AspNetCore.Http.EndpointMetadataCollection; internal sealed class UnsupportedApiVersionEndpoint : Endpoint @@ -14,30 +11,6 @@ internal sealed class UnsupportedApiVersionEndpoint : Endpoint internal UnsupportedApiVersionEndpoint() : base( OnExecute, Empty, Name ) { } - private static Task OnExecute( HttpContext context ) - { - var services = context.RequestServices; - var factory = services.GetRequiredService(); - var url = new Uri( context.Request.GetDisplayUrl() ).SafeFullPath(); - var apiVersion = context.ApiVersioningFeature().RawRequestedApiVersion; - var (type, title) = ProblemDetailsDefaults.Unsupported; - var detail = string.Format( - CultureInfo.CurrentCulture, - SR.VersionedResourceNotSupported, - url, - apiVersion ); - var problem = factory.CreateProblemDetails( - context.Request, - StatusCodes.Status400BadRequest, - title, - type, - detail ); - - context.Response.StatusCode = StatusCodes.Status400BadRequest; - - return context.Response.WriteAsJsonAsync( - problem, - options: default, - contentType: ProblemDetailsDefaults.MediaType.Json ); - } + private static Task OnExecute( HttpContext context ) => + EndpointProblem.UnsupportedApiVersion( context, StatusCodes.Status400BadRequest ); } \ 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 0c2fb2ff..b164bf20 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/UnsupportedMediaTypeEndpoint.cs @@ -11,9 +11,6 @@ internal sealed class UnsupportedMediaTypeEndpoint : Endpoint internal UnsupportedMediaTypeEndpoint() : base( OnExecute, Empty, Name ) { } - private static Task OnExecute( HttpContext context ) - { - context.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; - return Task.CompletedTask; - } + private static Task OnExecute( HttpContext context ) => + EndpointProblem.UnsupportedApiVersion( context, StatusCodes.Status415UnsupportedMediaType ); } \ No newline at end of file From 80f82031e8808cbc5ee3a4a30986e74a45ae0970 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 4 Nov 2022 18:33:51 -0700 Subject: [PATCH 04/15] Refactor Web API route parsing into core library --- .../ForwardedTypes.cs | 14 ++++++++++++++ .../Routing/BoundRouteTemplateAdapter{T}.cs | 0 .../Routing/IBoundRouteTemplate.cs | 0 .../Routing/IParsedRoute.cs | 0 .../Routing/IPathContentSegment.cs | 0 .../Routing/IPathLiteralSubsegment.cs | 0 .../Routing/IPathParameterSubsegment.cs | 0 .../Routing/IPathSegment.cs | 0 .../Routing/IPathSeparatorSegment.cs | 0 .../Routing/IPathSubsegment.cs | 0 .../Routing/ParsedRouteAdapter{T}.cs | 0 .../Routing/PathContentSegmentAdapter{T}.cs | 0 .../Routing/PathLiteralSubsegmentAdapter{T}.cs | 0 .../Routing/PathParameterSubsegmentAdapter{T}.cs | 0 .../Routing/PathSeparatorSegmentAdapter{T}.cs | 0 .../Routing/RouteParser.cs | 0 16 files changed, 14 insertions(+) create mode 100644 src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ForwardedTypes.cs rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/BoundRouteTemplateAdapter{T}.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IBoundRouteTemplate.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IParsedRoute.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IPathContentSegment.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IPathLiteralSubsegment.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IPathParameterSubsegment.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IPathSegment.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IPathSeparatorSegment.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/IPathSubsegment.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/ParsedRouteAdapter{T}.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/PathContentSegmentAdapter{T}.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/PathLiteralSubsegmentAdapter{T}.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/PathParameterSubsegmentAdapter{T}.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/PathSeparatorSegmentAdapter{T}.cs (100%) rename src/AspNet/WebApi/src/{Asp.Versioning.WebApi.ApiExplorer => Asp.Versioning.WebApi}/Routing/RouteParser.cs (100%) diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ForwardedTypes.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ForwardedTypes.cs new file mode 100644 index 00000000..fa2e628c --- /dev/null +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ForwardedTypes.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +using Asp.Versioning.Routing; +using System.Runtime.CompilerServices; + +[assembly: TypeForwardedTo( typeof( IBoundRouteTemplate ) )] +[assembly: TypeForwardedTo( typeof( IParsedRoute ) )] +[assembly: TypeForwardedTo( typeof( IPathContentSegment ) )] +[assembly: TypeForwardedTo( typeof( IPathLiteralSubsegment ) )] +[assembly: TypeForwardedTo( typeof( IPathParameterSubsegment ) )] +[assembly: TypeForwardedTo( typeof( IPathSegment ) )] +[assembly: TypeForwardedTo( typeof( IPathSeparatorSegment ) )] +[assembly: TypeForwardedTo( typeof( IPathSubsegment ) )] +[assembly: TypeForwardedTo( typeof( RouteParser ) )] \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/BoundRouteTemplateAdapter{T}.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/BoundRouteTemplateAdapter{T}.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/BoundRouteTemplateAdapter{T}.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/BoundRouteTemplateAdapter{T}.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IBoundRouteTemplate.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IBoundRouteTemplate.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IBoundRouteTemplate.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IBoundRouteTemplate.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IParsedRoute.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IParsedRoute.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IParsedRoute.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IParsedRoute.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathContentSegment.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathContentSegment.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathContentSegment.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathContentSegment.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathLiteralSubsegment.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathLiteralSubsegment.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathLiteralSubsegment.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathLiteralSubsegment.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathParameterSubsegment.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathParameterSubsegment.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathParameterSubsegment.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathParameterSubsegment.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathSegment.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathSegment.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathSegment.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathSegment.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathSeparatorSegment.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathSeparatorSegment.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathSeparatorSegment.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathSeparatorSegment.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathSubsegment.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathSubsegment.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/IPathSubsegment.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/IPathSubsegment.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/ParsedRouteAdapter{T}.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/ParsedRouteAdapter{T}.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/ParsedRouteAdapter{T}.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/ParsedRouteAdapter{T}.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathContentSegmentAdapter{T}.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathContentSegmentAdapter{T}.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathContentSegmentAdapter{T}.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathContentSegmentAdapter{T}.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathLiteralSubsegmentAdapter{T}.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathLiteralSubsegmentAdapter{T}.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathLiteralSubsegmentAdapter{T}.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathLiteralSubsegmentAdapter{T}.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathParameterSubsegmentAdapter{T}.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathParameterSubsegmentAdapter{T}.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathParameterSubsegmentAdapter{T}.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathParameterSubsegmentAdapter{T}.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathSeparatorSegmentAdapter{T}.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathSeparatorSegmentAdapter{T}.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/PathSeparatorSegmentAdapter{T}.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/PathSeparatorSegmentAdapter{T}.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/RouteParser.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/RouteParser.cs similarity index 100% rename from src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Routing/RouteParser.cs rename to src/AspNet/WebApi/src/Asp.Versioning.WebApi/Routing/RouteParser.cs From d4b6cfcb2a6d182d370af5dcd7c4e02bf9b4ee36 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 4 Nov 2022 18:34:43 -0700 Subject: [PATCH 05/15] Add missing perf optimization --- src/Common/src/Common/QueryStringApiVersionReader.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Common/src/Common/QueryStringApiVersionReader.cs b/src/Common/src/Common/QueryStringApiVersionReader.cs index f7c68d47..a32bde4c 100644 --- a/src/Common/src/Common/QueryStringApiVersionReader.cs +++ b/src/Common/src/Common/QueryStringApiVersionReader.cs @@ -2,6 +2,9 @@ namespace Asp.Versioning; +#if !NETFRAMEWORK +using System.Buffers; +#endif using static Asp.Versioning.ApiVersionParameterLocation; using static System.StringComparer; @@ -80,7 +83,12 @@ public virtual void AddParameters( IApiVersionParameterDescriptionContext contex } var count = ParameterNames.Count; +#if NETFRAMEWORK var names = new string[count]; +#else + var pool = ArrayPool.Shared; + var names = pool.Rent( count ); +#endif ParameterNames.CopyTo( names, 0 ); @@ -88,5 +96,9 @@ public virtual void AddParameters( IApiVersionParameterDescriptionContext contex { context.AddParameter( names[i], Query ); } + +#if !NETFRAMEWORK + pool.Return( names ); +#endif } } \ No newline at end of file From 512b2f19574b2612e68b3b5188c2d2e707ce56c9 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 4 Nov 2022 18:37:09 -0700 Subject: [PATCH 06/15] Add complex media type API version reader support. Resolves #887 --- .../MediaTypeApiVersionReaderBuilder.cs | 123 +++++ .../MediaTypeApiVersionReaderBuilderTest.cs | 415 +++++++++++++++++ .../MediaTypeApiVersionReaderBuilder.cs | 114 +++++ .../MediaTypeApiVersionBuilderTest.cs | 403 ++++++++++++++++ .../MediaTypeApiVersionReaderTest.cs | 17 + src/Common/src/Common/CommonSR.Designer.cs | 11 + src/Common/src/Common/CommonSR.resx | 3 + .../MediaTypeApiVersionReaderBuilder.cs | 433 ++++++++++++++++++ ...iaTypeApiVersionReaderBuilderExtensions.cs | 47 ++ 9 files changed, 1566 insertions(+) create mode 100644 src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs create mode 100644 src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs create mode 100644 src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs create mode 100644 src/Common/src/Common/MediaTypeApiVersionReaderBuilderExtensions.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs new file mode 100644 index 00000000..4f94837e --- /dev/null +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs @@ -0,0 +1,123 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Asp.Versioning.Routing; +using System.Globalization; +using System.Net.Http.Headers; +using System.Web.Http.Routing; + +/// +/// Provides additional implementation specific to ASP.NET Web API. +/// +public partial class MediaTypeApiVersionReaderBuilder +{ + /// + /// Adds a template used to read an API version from a media type. + /// + /// The template used to match the media type. + /// The optional name of the API version parameter in the template. + /// If a value is not specified, there is expected to be a single template parameter. + /// The current . + /// The template syntax is the same used by route templates; however, constraints are not supported. +#pragma warning disable CA1716 // Identifiers should not match keywords + public virtual MediaTypeApiVersionReaderBuilder Template( string template, string? parameterName = default ) +#pragma warning restore CA1716 // Identifiers should not match keywords + { + if ( string.IsNullOrEmpty( template ) ) + { + throw new ArgumentNullException( nameof( template ) ); + } + + if ( string.IsNullOrEmpty( parameterName ) ) + { + var parser = new RouteParser(); + var parsedRoute = parser.Parse( template ); + var parameters = from content in parsedRoute.PathSegments.OfType() + from parameter in content.Subsegments.OfType() + select parameter; + + if ( parameters.Count() > 1 ) + { + var message = string.Format( CultureInfo.CurrentCulture, CommonSR.InvalidMediaTypeTemplate, template ); + throw new ArgumentException( message, nameof( template ) ); + } + } + + var route = new HttpRoute( template ); + + AddReader( mediaTypes => ReadMediaTypePattern( mediaTypes, route, parameterName ) ); + + return this; + } + + private static IReadOnlyList ReadMediaTypePattern( + IReadOnlyList mediaTypes, + HttpRoute route, + string? parameterName ) + { + var assumeOneParameter = string.IsNullOrEmpty( parameterName ); + var version = default( string ); + var versions = default( List ); + using var request = new HttpRequestMessage(); + + for ( var i = 0; i < mediaTypes.Count; i++ ) + { + var mediaType = mediaTypes[i].MediaType; + request.RequestUri = new Uri( "http://localhost/" + mediaType ); + var data = route.GetRouteData( string.Empty, request ); + + if ( data == null ) + { + continue; + } + + var values = data.Values; + + if ( values.Count == 0 ) + { + continue; + } + + object datum; + + if ( assumeOneParameter ) + { + datum = values.Values.First(); + } + else if ( !values.TryGetValue( parameterName, out datum ) ) + { + continue; + } + + if ( datum is not string value || string.IsNullOrEmpty( value ) ) + { + continue; + } + + if ( version == null ) + { + version = value; + } + else if ( versions == null ) + { + versions = new( capacity: mediaTypes.Count - i + 1 ) + { + version, + value, + }; + } + else + { + versions.Add( value ); + } + } + + if ( version is null ) + { + return Array.Empty(); + } + + return versions is null ? new[] { version } : versions.ToArray(); + } +} \ No newline at end of file diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs new file mode 100644 index 00000000..b980fc31 --- /dev/null +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs @@ -0,0 +1,415 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using System.Net.Http; +using static ApiVersionParameterLocation; +using static System.Net.Http.Headers.MediaTypeWithQualityHeaderValue; +using static System.Net.Http.HttpMethod; +using static System.Text.Encoding; + +public class MediaTypeApiVersionReaderBuilderTest +{ + [Fact] + public void read_should_return_empty_list_when_media_type_is_unspecified() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ); + + // act + var versions = reader.Read( request ); + + // assert + versions.Should().BeEmpty(); + } + + [Fact] + public void read_should_retrieve_version_from_content_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/json;v=2.0" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void read_should_retrieve_version_from_accept() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ) + { + Headers = + { + Accept = { Parse( "application/json;v=2.0" ) }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Theory] + [InlineData( new[] { "application/json;q=1;v=2.0" }, "2.0" )] + [InlineData( new[] { "application/json;q=0.8;v=1.0", "text/plain" }, "1.0" )] + [InlineData( new[] { "application/json;q=0.5;v=3.0", "application/xml;q=0.5;v=3.0" }, "3.0" )] + [InlineData( new[] { "application/xml", "application/json;q=0.2;v=1.0" }, "1.0" )] + [InlineData( new[] { "application/json", "application/xml" }, null )] + [InlineData( new[] { "application/xml", "application/xml+atom;q=0.8;api.ver=2.5", "application/json;q=0.2;v=1.0" }, "2.5" )] + public void read_should_retrieve_version_from_accept_with_quality( string[] mediaTypes, string expected ) + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .Parameter( "api.ver" ) + .Select( ( request, versions ) => versions.Count == 0 ? versions : new[] { versions[versions.Count - 1] } ) + .Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ); + + foreach ( var mediaType in mediaTypes ) + { + request.Headers.Accept.Add( Parse( mediaType ) ); + } + + // act + var versions = reader.Read( request ); + + // assert + versions.SingleOrDefault().Should().Be( expected ); + } + + [Fact] + public void read_should_retrieve_version_from_content_type_and_accept() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Headers = + { + Accept = + { + Parse( "application/xml" ), + Parse( "application/xml+atom;q=0.8;v=1.5" ), + Parse( "application/json;q=0.2;v=2.0" ), + }, + }, + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/json;v=2.0" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Should().BeEquivalentTo( new[] { "1.5", "2.0" } ); + } + + [Fact] + public void read_should_match_value_from_accept() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Match( @"\d+" ).Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ) + { + Headers = + { + Accept = { Parse( "application/vnd-v2+json" ) }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2" ); + } + + [Fact] + public void read_should_match_group_from_content_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Match( @"-v(\d+(\.\d+)?)\+" ).Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/vnd-v2.1+json" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2.1" ); + } + + [Fact] + public void read_should_ignore_excluded_media_types() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .Exclude( "application/xml" ) + .Exclude( "application/xml+atom" ) + .Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Headers = + { + Accept = + { + Parse( "application/xml" ), + Parse( "application/xml+atom;q=0.8;v=1.5" ), + Parse( "application/json;q=0.2;v=2.0" ), + }, + }, + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/json;v=2.0" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void read_should_only_retrieve_included_media_types() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .Include( "application/json" ) + .Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Headers = + { + Accept = + { + Parse( "application/xml" ), + Parse( "application/xml+atom;q=0.8;v=1.5" ), + Parse( "application/json;q=0.2;v=2.0" ), + }, + }, + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/json;v=2.0" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Theory] + [InlineData( "application/vnd-v{v}+json", "v", "application/vnd-v2.1+json", "2.1" )] + [InlineData( "application/vnd-v{ver}+json", "ver", "application/vnd-v2022-11-01+json", "2022-11-01" )] + [InlineData( "application/vnd-{version}+xml", "version", "application/vnd-1.1-beta+xml", "1.1-beta" )] + public void read_should_retreive_version_from_media_type_template( + string template, + string parameterName, + string mediaType, + string expected ) + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Template( template, parameterName ).Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ) + { + Headers = + { + Accept = { Parse( mediaType ) }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( expected ); + } + + [Fact] + public void read_should_assume_version_from_single_parameter_in_media_type_template() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Template( "application/vnd-v{ver}+json" ) + .Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ) + { + Headers = + { + Accept = { Parse( "application/vnd-v1+json" ) }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "1" ); + } + + [Fact] + public void read_should_throw_exception_with_multiple_parameters_and_no_name() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder(); + + // act + var template = () => reader.Template( "application/vnd-v{ver}+json+{other}" ); + + // assert + template.Should().Throw().And + .ParamName.Should().Be( nameof( template ) ); + } + + [Fact] + public void read_should_return_empty_list_when_template_does_not_match() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Template( "application/vnd-v{ver}+json", "ver" ) + .Build(); + var request = new HttpRequestMessage( Get, "http://tempuri.org" ) + { + Headers = + { + Accept = { Parse( "text/plain" ) }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Should().BeEmpty(); + } + + [Fact] + public void read_should_select_first_version() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .SelectFirstOrDefault() + .Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Headers = + { + Accept = + { + Parse( "application/xml" ), + Parse( "application/xml+atom;q=0.8;v=1.5" ), + Parse( "application/json;q=0.2;v=2.0" ), + }, + }, + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/json;v=2.0" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "1.5" ); + } + + [Fact] + public void read_should_select_last_version() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .SelectLastOrDefault() + .Build(); + var request = new HttpRequestMessage( Post, "http://tempuri.org" ) + { + Headers = + { + Accept = + { + Parse( "application/xml" ), + Parse( "application/xml+atom;q=0.8;v=1.5" ), + Parse( "application/json;q=0.2;v=2.0" ), + }, + }, + Content = new StringContent( "{\"message\":\"test\"}", UTF8 ) + { + Headers = + { + ContentType = Parse( "application/json;v=2.0" ), + }, + }, + }; + + // act + var versions = reader.Read( request ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void add_parameters_should_add_parameter_for_media_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var context = new Mock(); + + context.Setup( c => c.AddParameter( It.IsAny(), It.IsAny() ) ); + + // act + reader.AddParameters( context.Object ); + + // assert + context.Verify( c => c.AddParameter( "v", MediaTypeParameter ), Times.Once() ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs new file mode 100644 index 00000000..45655770 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Routing.Template; +using Microsoft.Net.Http.Headers; +using System.Globalization; + +/// +/// Provides additional implementation specific to ASP.NET Core. +/// +public partial class MediaTypeApiVersionReaderBuilder +{ + /// + /// Adds a template used to read an API version from a media type. + /// + /// The template used to match the media type. + /// The optional name of the API version parameter in the template. + /// If a value is not specified, there is expected to be a single template parameter. + /// The current . + /// The template syntax is the same used by route templates; however, constraints are not supported. +#pragma warning disable CA1716 // Identifiers should not match keywords + public virtual MediaTypeApiVersionReaderBuilder Template( string template, string? parameterName = default ) +#pragma warning restore CA1716 // Identifiers should not match keywords + { + if ( string.IsNullOrEmpty( template ) ) + { + throw new ArgumentNullException( nameof( template ) ); + } + + var routePattern = RoutePatternFactory.Parse( template ); + + if ( string.IsNullOrEmpty( parameterName ) && routePattern.Parameters.Count > 1 ) + { + var message = string.Format( CultureInfo.CurrentCulture, CommonSR.InvalidMediaTypeTemplate, template ); + throw new ArgumentException( message, nameof( template ) ); + } + + var defaults = new RouteValueDictionary( routePattern.RequiredValues ); + var matcher = new TemplateMatcher( new( routePattern ), defaults ); + + AddReader( mediaTypes => ReadMediaTypePattern( mediaTypes, matcher, parameterName ) ); + + return this; + } + + private static IReadOnlyList ReadMediaTypePattern( + IReadOnlyList mediaTypes, + TemplateMatcher matcher, + string? parameterName ) + { + const char RequiredPrefix = '/'; + var assumeOneParameter = string.IsNullOrEmpty( parameterName ); + var version = default( string ); + var versions = default( List ); + var values = new RouteValueDictionary(); + + for ( var i = 0; i < mediaTypes.Count; i++ ) + { + var mediaType = mediaTypes[i].MediaType.Value; + var path = new PathString( RequiredPrefix + mediaType ); + + values.Clear(); + + if ( !matcher.TryMatch( path, values ) || values.Count == 0 ) + { + continue; + } + + object? datum; + + if ( assumeOneParameter ) + { + datum = values.Values.First(); + } + else if ( !values.TryGetValue( parameterName!, out datum ) ) + { + continue; + } + + if ( datum is not string value || string.IsNullOrEmpty( value ) ) + { + continue; + } + + if ( version == null ) + { + version = value; + } + else if ( versions == null ) + { + versions = new( capacity: mediaTypes.Count - i + 1 ) + { + version, + value, + }; + } + else + { + versions.Add( value ); + } + } + + if ( version is null ) + { + return Array.Empty(); + } + + return versions is null ? new[] { version } : versions.ToArray(); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs new file mode 100644 index 00000000..fc842c09 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs @@ -0,0 +1,403 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using static ApiVersionParameterLocation; +using static System.IO.Stream; + +public class MediaTypeApiVersionBuilderTest +{ + [Fact] + public void read_should_return_empty_list_when_media_type_is_unspecified() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Build(); + var request = new Mock(); + + request.SetupGet( r => r.Headers ).Returns( Mock.Of() ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Should().BeEmpty(); + } + + [Fact] + public void read_should_retrieve_version_from_content_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var request = new Mock(); + var headers = new Mock(); + + headers.SetupGet( h => h["Content-Type"] ).Returns( new StringValues( "application/json;v=2.0" ) ); + request.SetupGet( r => r.Headers ).Returns( headers.Object ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/json;v=2.0" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void read_should_retrieve_version_from_accept() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var request = new Mock(); + var headers = new HeaderDictionary() + { + ["Accept"] = "application/json;v=2.0", + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Theory] + [InlineData( new[] { "application/json;q=1;v=2.0" }, "2.0" )] + [InlineData( new[] { "application/json;q=0.8;v=1.0", "text/plain" }, "1.0" )] + [InlineData( new[] { "application/json;q=0.5;v=3.0", "application/xml;q=0.5;v=3.0" }, "3.0" )] + [InlineData( new[] { "application/xml", "application/json;q=0.2;v=1.0" }, "1.0" )] + [InlineData( new[] { "application/json", "application/xml" }, null )] + [InlineData( new[] { "application/xml", "application/xml+atom;q=0.8;api.ver=2.5", "application/json;q=0.2;v=1.0" }, "2.5" )] + public void read_should_retrieve_version_from_accept_with_quality( string[] mediaTypes, string expected ) + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .Parameter( "api.ver" ) + .Select( ( request, versions ) => versions.Count == 0 ? versions : new[] { versions[^1] } ) + .Build(); + var request = new Mock(); + var headers = new HeaderDictionary() + { + ["Accept"] = new StringValues( mediaTypes ), + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.SingleOrDefault().Should().Be( expected ); + } + + [Fact] + public void read_should_retrieve_version_from_content_type_and_accept() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var request = new Mock(); + var mediaTypes = new[] + { + "application/xml", + "application/xml+atom;q=0.8;v=1.5", + "application/json;q=0.2;v=2.0", + }; + var headers = new HeaderDictionary() + { + ["Accept"] = new StringValues( mediaTypes ), + ["Content-Type"] = new StringValues( "application/json;v=2.0" ), + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/json;v=2.0" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Should().BeEquivalentTo( "1.5", "2.0" ); + } + + [Fact] + public void read_should_match_value_from_accept() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Match( @"\d+" ).Build(); + var request = new Mock(); + var headers = new HeaderDictionary() + { + ["Accept"] = "application/vnd-v2+json", + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2" ); + } + + [Fact] + public void read_should_match_group_from_content_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Match( @"-v(\d+(\.\d+)?)\+" ).Build(); + var request = new Mock(); + var headers = new Mock(); + + headers.SetupGet( h => h["Content-Type"] ).Returns( new StringValues( "application/vnd-v2.1+json" ) ); + request.SetupGet( r => r.Headers ).Returns( headers.Object ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/vnd-v2.1+json" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2.1" ); + } + + [Fact] + public void read_should_ignore_excluded_media_types() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .Exclude( "application/xml" ) + .Exclude( "application/xml+atom" ) + .Build(); + var request = new Mock(); + var mediaTypes = new[] + { + "application/xml", + "application/xml+atom;q=0.8;v=1.5", + "application/json;q=0.2;v=2.0", + }; + var headers = new HeaderDictionary() + { + ["Accept"] = new StringValues( mediaTypes ), + ["Content-Type"] = new StringValues( "application/json;v=2.0" ), + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/json;v=2.0" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void read_should_only_retrieve_included_media_types() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .Include( "application/json" ) + .Build(); + var request = new Mock(); + var mediaTypes = new[] + { + "application/xml", + "application/xml+atom;q=0.8;v=1.5", + "application/json;q=0.2;v=2.0", + }; + var headers = new HeaderDictionary() + { + ["Accept"] = new StringValues( mediaTypes ), + ["Content-Type"] = new StringValues( "application/json;v=2.0" ), + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/json;v=2.0" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void read_should_assume_version_from_single_parameter_in_media_type_template() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Template( "application/vnd-v{ver}+json" ) + .Build(); + var request = new Mock(); + var headers = new HeaderDictionary() + { + ["Accept"] = "application/vnd-v1+json", + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "1" ); + } + + [Theory] + [InlineData( "application/vnd-v{v}+json", "v", "application/vnd-v2.1+json", "2.1" )] + [InlineData( "application/vnd-v{ver}+json", "ver", "application/vnd-v2022-11-01+json", "2022-11-01" )] + [InlineData( "application/vnd-{version}+xml", "version", "application/vnd-1.1-beta+xml", "1.1-beta" )] + public void read_should_retreive_version_from_media_type_template( + string template, + string parameterName, + string mediaType, + string expected ) + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Template( template, parameterName ).Build(); + var request = new Mock(); + var headers = new HeaderDictionary() + { + ["Accept"] = mediaType, + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( expected ); + } + + [Fact] + public void read_should_throw_exception_with_multiple_parameters_and_no_name() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder(); + + // act + var template = () => reader.Template( "application/vnd-v{ver}+json+{other}" ); + + // assert + template.Should().Throw().And + .ParamName.Should().Be( nameof( template ) ); + } + + [Fact] + public void read_should_return_empty_list_when_template_does_not_match() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Template( "application/vnd-v{ver}+json", "ver" ) + .Build(); + var request = new Mock(); + var headers = new HeaderDictionary() + { + ["Accept"] = "text/plain", + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Should().BeEmpty(); + } + + [Fact] + public void read_should_select_first_version() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .SelectFirstOrDefault() + .Build(); + var request = new Mock(); + var mediaTypes = new[] + { + "application/xml", + "application/xml+atom;q=0.8;v=1.5", + "application/json;q=0.2;v=2.0", + }; + var headers = new HeaderDictionary() + { + ["Accept"] = new StringValues( mediaTypes ), + ["Content-Type"] = new StringValues( "application/json;v=2.0" ), + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/json;v=2.0" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "1.5" ); + } + + [Fact] + public void read_should_select_last_version() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder() + .Parameter( "v" ) + .SelectLastOrDefault() + .Build(); + var request = new Mock(); + var mediaTypes = new[] + { + "application/xml", + "application/xml+atom;q=0.8;v=1.5", + "application/json;q=0.2;v=2.0", + }; + var headers = new HeaderDictionary() + { + ["Accept"] = new StringValues( mediaTypes ), + ["Content-Type"] = new StringValues( "application/json;v=2.0" ), + }; + + request.SetupGet( r => r.Headers ).Returns( headers ); + request.SetupProperty( r => r.Body, Null ); + request.SetupProperty( r => r.ContentLength, 0L ); + request.SetupProperty( r => r.ContentType, "application/json;v=2.0" ); + + // act + var versions = reader.Read( request.Object ); + + // assert + versions.Single().Should().Be( "2.0" ); + } + + [Fact] + public void add_parameters_should_add_parameter_for_media_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var context = new Mock(); + + context.Setup( c => c.AddParameter( It.IsAny(), It.IsAny() ) ); + + // act + reader.AddParameters( context.Object ); + + // assert + context.Verify( c => c.AddParameter( "v", MediaTypeParameter ), Times.Once() ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs index 8c931741..43b097cc 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs @@ -4,6 +4,7 @@ namespace Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; +using static ApiVersionParameterLocation; using static System.IO.Stream; public class MediaTypeApiVersionReaderTest @@ -163,4 +164,20 @@ public void read_should_retrieve_version_from_accept_with_custom_parameter() // assert versions.Single().Should().Be( "3.0" ); } + + [Fact] + public void add_parameters_should_add_parameter_for_media_type() + { + // arrange + var reader = new MediaTypeApiVersionReader(); + var context = new Mock(); + + context.Setup( c => c.AddParameter( It.IsAny(), It.IsAny() ) ); + + // act + reader.AddParameters( context.Object ); + + // assert + context.Verify( c => c.AddParameter( "v", MediaTypeParameter ), Times.Once() ); + } } \ No newline at end of file diff --git a/src/Common/src/Common/CommonSR.Designer.cs b/src/Common/src/Common/CommonSR.Designer.cs index 099039cd..bb9844a6 100644 --- a/src/Common/src/Common/CommonSR.Designer.cs +++ b/src/Common/src/Common/CommonSR.Designer.cs @@ -90,5 +90,16 @@ internal static string ZeroApiVersionReaders return ResourceManager.GetString( "ZeroApiVersionReaders", resourceCulture ); } } + + /// + /// Looks up a localized string similar to The template '{0}' has more than one parameter and no parameter name was specified.. + /// + internal static string InvalidMediaTypeTemplate + { + get + { + return ResourceManager.GetString( "InvalidMediaTypeTemplate", resourceCulture ); + } + } } } diff --git a/src/Common/src/Common/CommonSR.resx b/src/Common/src/Common/CommonSR.resx index 842b7e5f..d6f616ee 100644 --- a/src/Common/src/Common/CommonSR.resx +++ b/src/Common/src/Common/CommonSR.resx @@ -126,4 +126,7 @@ At least one IApiVersionReader must be specified. + + The template '{0}' has more than one parameter and no parameter name was specified. + \ No newline at end of file diff --git a/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs b/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs new file mode 100644 index 00000000..6b012abe --- /dev/null +++ b/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs @@ -0,0 +1,433 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable IDE0079 +#pragma warning disable SA1121 + +namespace Asp.Versioning; + +#if NETFRAMEWORK +using System.Net.Http.Headers; +#else +using Microsoft.AspNetCore.Http; +using System.Buffers; +#endif +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +#if NETFRAMEWORK +using HttpRequest = System.Net.Http.HttpRequestMessage; +using Str = System.String; +using StrComparer = System.StringComparer; +#else +using MediaTypeWithQualityHeaderValue = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; +using Str = Microsoft.Extensions.Primitives.StringSegment; +using StrComparer = Microsoft.Extensions.Primitives.StringSegmentComparer; +#endif +using static Asp.Versioning.ApiVersionParameterLocation; +using static System.StringComparison; + +/// +/// Represents a builder for an API version reader that reads the value from a media type HTTP header in the request. +/// +public partial class MediaTypeApiVersionReaderBuilder +{ + private HashSet? parameters; + private HashSet? included; + private HashSet? excluded; + private Func, IReadOnlyList>? select; + private List, IReadOnlyList>>? readers; + + /// + /// Adds the name of a media type parameter to be read. + /// + /// The name of the media type parameter. + /// The current . + public virtual MediaTypeApiVersionReaderBuilder Parameter( string name ) + { + if ( !string.IsNullOrEmpty( name ) ) + { + parameters ??= new( StringComparer.OrdinalIgnoreCase ); + parameters.Add( name ); + AddReader( mediaTypes => ReadMediaTypeParameter( mediaTypes, name ) ); + } + + return this; + } + + /// + /// Excludes the specified media type from being read. + /// + /// The name of the media type to exclude. + /// The current . + public virtual MediaTypeApiVersionReaderBuilder Exclude( string name ) + { + if ( !string.IsNullOrEmpty( name ) ) + { + excluded ??= new( StrComparer.OrdinalIgnoreCase ); + excluded.Add( name ); + } + + return this; + } + + /// + /// Includes the specified media type to be read. + /// + /// The name of the media type to include. + /// The current . + public virtual MediaTypeApiVersionReaderBuilder Include( string name ) + { + if ( !string.IsNullOrEmpty( name ) ) + { + included ??= new( StrComparer.OrdinalIgnoreCase ); + included.Add( name ); + } + + return this; + } + + /// + /// Adds a pattern used to read an API version from a media type. + /// + /// The regular expression used to match the API version in the media type. + /// The current . + public virtual MediaTypeApiVersionReaderBuilder Match( string pattern ) + { + // TODO: in .NET 7 add [StringSyntax( StringSyntaxAttribute.Regex )] + if ( !string.IsNullOrEmpty( pattern ) ) + { + AddReader( mediaTypes => ReadMediaType( mediaTypes, pattern ) ); + } + + return this; + } + + /// + /// Selects one or more raw API versions read from media types. + /// + /// The function used to select results. + /// The current . + /// The selector will only be invoked if there is more than one value. +#if !NETFRAMEWORK + [CLSCompliant( false )] +#endif +#pragma warning disable CA1716 // Identifiers should not match keywords + public virtual MediaTypeApiVersionReaderBuilder Select( Func, IReadOnlyList> selector ) +#pragma warning restore CA1716 // Identifiers should not match keywords + { + select = selector; + return this; + } + + /// + /// Creates and returns a new API version reader. + /// + /// A new API version reader. +#if !NETFRAMEWORK + [CLSCompliant( false )] +#endif + public virtual IApiVersionReader Build() => + new BuiltMediaTypeApiVersionReader( + parameters?.ToArray() ?? Array.Empty(), + included ?? EmptyCollection(), + excluded ?? EmptyCollection(), + select ?? DefaultSelector, + readers ?? EmptyList() ); + + /// + /// Adds a function used to read the an API version from one or more media types. + /// + /// The function used to read the API version. + /// is null. +#if !NETFRAMEWORK + [CLSCompliant( false )] +#endif + protected void AddReader( Func, IReadOnlyList> reader ) + { + if ( reader is null ) + { + throw new ArgumentNullException( nameof( reader ) ); + } + + readers ??= new(); + readers.Add( reader ); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static ICollection EmptyCollection() => Array.Empty(); + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static IReadOnlyList, IReadOnlyList>> EmptyList() => + Array.Empty, IReadOnlyList>>(); + + private static IReadOnlyList DefaultSelector( HttpRequest request, IReadOnlyList versions ) => versions; + + private static IReadOnlyList ReadMediaType( + IReadOnlyList mediaTypes, + string pattern ) + { + var version = default( string ); + var versions = default( List ); + var regex = default( Regex ); + + for ( var i = 0; i < mediaTypes.Count; i++ ) + { + var mediaType = mediaTypes[i].MediaType; + + if ( Str.IsNullOrEmpty( mediaType ) ) + { + continue; + } + + regex ??= new( pattern, RegexOptions.Singleline ); + +#if NETFRAMEWORK + var input = mediaType; +#else + var input = mediaType.Value; +#endif + var match = regex.Match( input ); + + while ( match.Success ) + { + var groups = match.Groups; + var value = groups.Count > 1 ? groups[1].Value : match.Value; + + if ( version == null ) + { + version = value; + } + else if ( versions == null ) + { + versions = new( capacity: mediaTypes.Count - i + 1 ) + { + version, + value, + }; + } + else + { + versions.Add( value ); + } + + match = match.NextMatch(); + } + } + + if ( version is null ) + { + return Array.Empty(); + } + + return versions is null ? new[] { version } : versions.ToArray(); + } + + private static IReadOnlyList ReadMediaTypeParameter( + IReadOnlyList mediaTypes, + string parameterName ) + { + var version = default( string ); + var versions = default( List ); + + for ( var i = 0; i < mediaTypes.Count; i++ ) + { + var mediaType = mediaTypes[i]; + + foreach ( var parameter in mediaType.Parameters ) + { + if ( !Str.Equals( parameterName, parameter.Name, OrdinalIgnoreCase ) || + Str.IsNullOrEmpty( parameter.Value ) ) + { + continue; + } + +#if NETFRAMEWORK + var value = parameter.Value; +#else + var value = parameter.Value.Value; +#endif + if ( version == null ) + { + version = value; + } + else if ( versions == null ) + { + versions = new( capacity: mediaTypes.Count - i + 1 ) + { + version, + value, + }; + } + else + { + versions.Add( value ); + } + } + } + + if ( version is null ) + { + return Array.Empty(); + } + + return versions is null ? new[] { version } : versions.ToArray(); + } + + private sealed class BuiltMediaTypeApiVersionReader : IApiVersionReader + { + private readonly IReadOnlyList parameters; + private readonly ICollection included; + private readonly ICollection excluded; + private readonly Func, IReadOnlyList> selector; + private readonly IReadOnlyList, IReadOnlyList>> readers; + + internal BuiltMediaTypeApiVersionReader( + IReadOnlyList parameters, + ICollection included, + ICollection excluded, + Func, IReadOnlyList> selector, + IReadOnlyList, IReadOnlyList>> readers ) + { + this.parameters = parameters; + this.included = included; + this.excluded = excluded; + this.selector = selector; + this.readers = readers; + } + + public void AddParameters( IApiVersionParameterDescriptionContext context ) + { + if ( context == null ) + { + throw new ArgumentNullException( nameof( context ) ); + } + + for ( var i = 0; i < parameters.Count; i++ ) + { + context.AddParameter( parameters[i], MediaTypeParameter ); + } + } + + public IReadOnlyList Read( HttpRequest request ) + { + if ( readers.Count == 0 ) + { + return Array.Empty(); + } + +#if NETFRAMEWORK + var headers = request.Headers; + var contentType = request.Content?.Headers.ContentType; + var accept = headers.Accept; +#else + var headers = request.GetTypedHeaders(); + var contentType = headers.ContentType; + var accept = headers.Accept; +#endif + var version = default( string ); + var versions = default( SortedSet ); + var mediaTypes = default( List ); + + if ( contentType != null ) + { +#if NETFRAMEWORK + mediaTypes = new() { MediaTypeWithQualityHeaderValue.Parse( contentType.ToString() ) }; +#else + mediaTypes = new() { contentType }; +#endif + } + + if ( accept != null && accept.Count > 0 ) + { + mediaTypes ??= new( capacity: accept.Count ); + mediaTypes.AddRange( accept ); + } + + if ( mediaTypes == null ) + { + return Array.Empty(); + } + + Filter( mediaTypes ); + + switch ( mediaTypes.Count ) + { + case 0: + return Array.Empty(); + case 1: + break; + default: + mediaTypes.Sort( static ( l, r ) => -Nullable.Compare( l.Quality, r.Quality ) ); + break; + } + + Read( mediaTypes, ref version, ref versions ); + + if ( versions == null ) + { + return version == null ? Array.Empty() : new[] { version }; + } + + return selector( request, versions.ToArray() ); + } + + private void Filter( IList mediaTypes ) + { + if ( excluded.Count > 0 ) + { + for ( var i = mediaTypes.Count - 1; i >= 0; i-- ) + { + var mediaType = mediaTypes[i].MediaType; + + if ( Str.IsNullOrEmpty( mediaType ) || excluded.Contains( mediaType ) ) + { + mediaTypes.RemoveAt( i ); + } + } + } + + if ( included.Count == 0 ) + { + return; + } + + for ( var i = mediaTypes.Count - 1; i >= 0; i-- ) + { + if ( !included.Contains( mediaTypes[i].MediaType! ) ) + { + mediaTypes.RemoveAt( i ); + } + } + } + + private void Read( + List mediaTypes, + ref string? version, + ref SortedSet? versions ) + { + for ( var i = 0; i < readers.Count; i++ ) + { + var result = readers[i]( mediaTypes ); + + for ( var j = 0; j < result.Count; j++ ) + { + if ( version == null ) + { + version = result[j]; + } + else if ( versions == null ) + { + versions = new( StringComparer.OrdinalIgnoreCase ) + { + version, + result[j], + }; + } + else + { + versions.Add( result[j] ); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Common/src/Common/MediaTypeApiVersionReaderBuilderExtensions.cs b/src/Common/src/Common/MediaTypeApiVersionReaderBuilderExtensions.cs new file mode 100644 index 00000000..c91073b0 --- /dev/null +++ b/src/Common/src/Common/MediaTypeApiVersionReaderBuilderExtensions.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Provides extension methods for . +/// +public static class MediaTypeApiVersionReaderBuilderExtensions +{ + /// + /// Selects the first available API version, if there is one. + /// + /// The type of builder. + /// The extended builder. + /// The current builder. + /// This will likely select the lowest API version. + /// is null. + public static T SelectFirstOrDefault( this T builder ) where T : MediaTypeApiVersionReaderBuilder + { + if ( builder == null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + builder.Select( static ( request, versions ) => versions.Count == 0 ? versions : new[] { versions[0] } ); + return builder; + } + + /// + /// Selects the last available API version, if there is one. + /// + /// The type of builder. + /// The extended builder. + /// The current builder. + /// This will likely select the highest API version. + /// is null. + public static T SelectLastOrDefault( this T builder ) where T : MediaTypeApiVersionReaderBuilder + { + if ( builder == null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + builder.Select( static ( request, versions ) => versions.Count == 0 ? versions : new[] { versions[versions.Count - 1] } ); + return builder; + } +} \ No newline at end of file From 6e79c42b4ec5575d91f1180a46bdfde43a098ba0 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 4 Nov 2022 18:37:09 -0700 Subject: [PATCH 07/15] Add complex media type API version reader support. Resolves #887 --- src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs b/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs index 6b012abe..ca54d123 100644 --- a/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs +++ b/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs @@ -427,7 +427,7 @@ private void Read( versions.Add( result[j] ); } } - } } } + } } \ No newline at end of file From 55b5aad36ea8134bd0345bd70444ab2c57998192 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 6 Nov 2022 19:16:50 -0800 Subject: [PATCH 08/15] Add copy constructor --- .../ApiVersionMetadata.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs index b8cd78c6..4211a665 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionMetadata.cs @@ -28,6 +28,23 @@ public class ApiVersionMetadata Name = name ?? string.Empty; } + /// + /// Initializes a new instance of the class. + /// + /// The other instance to initialize from. + protected ApiVersionMetadata( ApiVersionMetadata other ) + { + if ( other == null ) + { + throw new ArgumentNullException( nameof( other ) ); + } + + apiModel = other.apiModel; + endpointModel = other.endpointModel; + mergedModel = other.mergedModel; + Name = other.Name; + } + /// /// Gets an empty API version information. /// From 462ec42d1914dbed2e7b1368e894c52b0ddbf919 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 6 Nov 2022 19:17:59 -0800 Subject: [PATCH 09/15] Add factory to create API version descriptor provider --- .../IApiVersionDescriptionProviderFactory.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionDescriptionProviderFactory.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionDescriptionProviderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionDescriptionProviderFactory.cs new file mode 100644 index 00000000..743ff374 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionDescriptionProviderFactory.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Routing; + +/// +/// Defines the behavior of a factory used to create a . +/// +[CLSCompliant( false )] +public interface IApiVersionDescriptionProviderFactory +{ + /// + /// Creates and returns an API version description provider. + /// + /// The endpoint data + /// source used by the provider. + /// A new API version description provider. + IApiVersionDescriptionProvider Create( EndpointDataSource endpointDataSource ); +} \ No newline at end of file From 40a13756d5c7ffc3d1f67ddfbcc6b287b1b08314 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 6 Nov 2022 19:19:08 -0800 Subject: [PATCH 10/15] Add support for custom group name formatting --- .../ApiExplorerOptions.cs | 9 + .../ApiVersionDescriptionProviderFactory.cs | 32 ++ .../IApiVersioningBuilderExtensions.cs | 51 +- .../FormatGroupNameCallback.cs | 11 + .../GroupedApiVersionDescriptionProvider.cs | 466 ++++++++++++++++++ .../IEndpointRouteBuilderExtensions.cs | 16 +- .../VersionedApiDescriptionProvider.cs | 11 +- 7 files changed, 578 insertions(+), 18 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/FormatGroupNameCallback.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptions.cs index 3231d1d2..0e0aef50 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptions.cs @@ -46,4 +46,13 @@ public IApiVersionParameterSource ApiVersionParameterSource /// /// The name associated with the API version route constraint. public string RouteConstraintName { get; set; } = string.Empty; + + /// + /// Gets or sets the function used to format the combination of a group name and API version. + /// + /// The callback used to format the combination of + /// a group name and API version. The default value is null. + /// The specified callback will only be invoked if a group name has been configured. The API + /// version will be provided formatted according to the group name format. + public FormatGroupNameCallback? FormatGroupName { get; set; } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs new file mode 100644 index 00000000..db350ec3 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Microsoft.AspNetCore.Builder; + +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +internal sealed class ApiVersionDescriptionProviderFactory : IApiVersionDescriptionProviderFactory +{ + private readonly IServiceProvider serviceProvider; + private readonly Func, IApiVersionDescriptionProvider> activator; + + public ApiVersionDescriptionProviderFactory( + IServiceProvider serviceProvider, + Func, IApiVersionDescriptionProvider> activator ) + { + this.serviceProvider = serviceProvider; + this.activator = activator; + } + + public IApiVersionDescriptionProvider Create( EndpointDataSource endpointDataSource ) + { + var actionDescriptorCollectionProvider = serviceProvider.GetRequiredService(); + var sunsetPolicyManager = serviceProvider.GetRequiredService(); + var options = serviceProvider.GetRequiredService>(); + return activator( endpointDataSource, actionDescriptorCollectionProvider, sunsetPolicyManager, options ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs index a845bf61..2763a3cf 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -4,7 +4,9 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -61,7 +63,8 @@ private static void AddApiExplorerServices( IServiceCollection services ) services.AddMvcCore().AddApiExplorer(); services.TryAddSingleton, ApiExplorerOptionsFactory>(); - services.TryAddSingleton(); + services.TryAddTransient( ResolveApiVersionDescriptionProviderFactory ); + services.TryAddSingleton( ResolveApiVersionDescriptionProvider ); // use internal constructor until ASP.NET Core fixes their bug // BUG: https://github.com/dotnet/aspnetcore/issues/41773 @@ -73,4 +76,50 @@ private static void AddApiExplorerServices( IServiceCollection services ) sp.GetRequiredService(), sp.GetRequiredService>() ) ) ); } + + private static IApiVersionDescriptionProviderFactory ResolveApiVersionDescriptionProviderFactory( IServiceProvider serviceProvider ) + { + var options = serviceProvider.GetRequiredService>(); + var mightUseCustomGroups = options.Value.FormatGroupName is not null; + + return new ApiVersionDescriptionProviderFactory( serviceProvider, mightUseCustomGroups ? NewGroupedProvider : NewDefaultProvider ); + + static IApiVersionDescriptionProvider NewDefaultProvider( + EndpointDataSource endpointDataSource, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, + ISunsetPolicyManager sunsetPolicyManager, + IOptions apiExplorerOptions ) => + new DefaultApiVersionDescriptionProvider( endpointDataSource, actionDescriptorCollectionProvider, sunsetPolicyManager, apiExplorerOptions ); + + static IApiVersionDescriptionProvider NewGroupedProvider( + EndpointDataSource endpointDataSource, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, + ISunsetPolicyManager sunsetPolicyManager, + IOptions apiExplorerOptions ) => + new GroupedApiVersionDescriptionProvider( endpointDataSource, actionDescriptorCollectionProvider, sunsetPolicyManager, apiExplorerOptions ); + } + + private static IApiVersionDescriptionProvider ResolveApiVersionDescriptionProvider( IServiceProvider serviceProvider ) + { + var endpointDataSource = serviceProvider.GetRequiredService(); + var actionDescriptorCollectionProvider = serviceProvider.GetRequiredService(); + var sunsetPolicyManager = serviceProvider.GetRequiredService(); + var options = serviceProvider.GetRequiredService>(); + var mightUseCustomGroups = options.Value.FormatGroupName is not null; + + if ( mightUseCustomGroups ) + { + return new GroupedApiVersionDescriptionProvider( + endpointDataSource, + actionDescriptorCollectionProvider, + sunsetPolicyManager, + options ); + } + + return new DefaultApiVersionDescriptionProvider( + endpointDataSource, + actionDescriptorCollectionProvider, + sunsetPolicyManager, + options ); + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/FormatGroupNameCallback.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/FormatGroupNameCallback.cs new file mode 100644 index 00000000..863a4bfd --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/FormatGroupNameCallback.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +/// +/// Represents a callback function used to format a group name. +/// +/// The associated group name. +/// A formatted API version. +/// The format result. +public delegate string FormatGroupNameCallback( string groupName, string apiVersion ); \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs new file mode 100644 index 00000000..7e53d9f3 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs @@ -0,0 +1,466 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using System.Buffers; +using static Asp.Versioning.ApiVersionMapping; +using static System.Globalization.CultureInfo; + +/// +/// Represents the default implementation of an object that discovers and describes the API version information within an application. +/// +[CLSCompliant( false )] +public class GroupedApiVersionDescriptionProvider : IApiVersionDescriptionProvider +{ + private readonly ApiVersionDescriptionCollection collection; + private readonly IOptions options; + + /// + /// Initializes a new instance of the class. + /// + /// The data source for endpoints. + /// The provider + /// used to enumerate the actions within an application. + /// The manager used to resolve sunset policies. + /// The container of configured + /// API explorer options. + public GroupedApiVersionDescriptionProvider( + EndpointDataSource endpointDataSource, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, + ISunsetPolicyManager sunsetPolicyManager, + IOptions apiExplorerOptions ) + { + collection = new( this, endpointDataSource, actionDescriptorCollectionProvider ); + SunsetPolicyManager = sunsetPolicyManager; + options = apiExplorerOptions; + } + + /// + /// Gets the manager used to resolve sunset policies. + /// + /// The associated sunset policy manager. + protected ISunsetPolicyManager SunsetPolicyManager { get; } + + /// + /// Gets the options associated with the API explorer. + /// + /// The current API explorer options. + protected ApiExplorerOptions Options => options.Value; + + /// + public IReadOnlyList ApiVersionDescriptions => collection.Items; + + /// + /// Provides a list of API version descriptions from a list of application API version metadata. + /// + /// The read-only list of + /// grouped API version metadata within the application. + /// A read-only list of API + /// version descriptions. + protected virtual IReadOnlyList Describe( IReadOnlyList metadata ) + { + if ( metadata == null ) + { + throw new ArgumentNullException( nameof( metadata ) ); + } + + var descriptions = new SortedSet( new ApiVersionDescriptionComparer() ); + var supported = new HashSet(); + var deprecated = new HashSet(); + + BucketizeApiVersions( metadata, supported, deprecated ); + AppendDescriptions( descriptions, supported, deprecated: false ); + AppendDescriptions( descriptions, deprecated, deprecated: true ); + + return descriptions.ToArray(); + } + + private void BucketizeApiVersions( + IReadOnlyList list, + ISet supported, + ISet deprecated ) + { + var declared = new HashSet(); + var advertisedSupported = new HashSet(); + var advertisedDeprecated = new HashSet(); + + for ( var i = 0; i < list.Count; i++ ) + { + var metadata = list[i]; + var groupName = metadata.GroupName; + var model = metadata.Map( Explicit | Implicit ); + var versions = model.DeclaredApiVersions; + + for ( var j = 0; j < versions.Count; j++ ) + { + declared.Add( new( groupName, versions[j] ) ); + } + + versions = model.SupportedApiVersions; + + for ( var j = 0; j < versions.Count; j++ ) + { + var version = versions[j]; + supported.Add( new( groupName, version ) ); + advertisedSupported.Add( new( groupName, version ) ); + } + + versions = model.DeprecatedApiVersions; + + for ( var j = 0; j < versions.Count; j++ ) + { + var version = versions[j]; + deprecated.Add( new( groupName, version ) ); + advertisedDeprecated.Add( new( groupName, version ) ); + } + } + + advertisedSupported.ExceptWith( declared ); + advertisedDeprecated.ExceptWith( declared ); + supported.ExceptWith( advertisedSupported ); + deprecated.ExceptWith( supported.Concat( advertisedDeprecated ) ); + + if ( supported.Count == 0 && deprecated.Count == 0 ) + { + supported.Add( new( default, Options.DefaultApiVersion ) ); + } + } + + private void AppendDescriptions( + ICollection descriptions, + IEnumerable versions, + bool deprecated ) + { + var format = Options.GroupNameFormat; + var formatGroupName = Options.FormatGroupName; + + foreach ( var (groupName, version) in versions ) + { + var formattedVersion = version.ToString( format, CurrentCulture ); + var formattedGroupName = + string.IsNullOrEmpty( groupName ) || formatGroupName is null + ? formattedVersion + : formatGroupName( groupName, formattedVersion ); + + var sunsetPolicy = SunsetPolicyManager.TryGetPolicy( version, out var policy ) ? policy : default; + descriptions.Add( new( version, formattedGroupName, deprecated, sunsetPolicy ) ); + } + } + + private sealed class ApiVersionDescriptionCollection + { + private readonly object syncRoot = new(); + private readonly GroupedApiVersionDescriptionProvider apiVersionDescriptionProvider; + private readonly EndpointApiVersionMetadataCollection endpoints; + private readonly ActionApiVersionMetadataCollection actions; + private IReadOnlyList? items; + private long version; + + public ApiVersionDescriptionCollection( + GroupedApiVersionDescriptionProvider apiVersionDescriptionProvider, + EndpointDataSource endpointDataSource, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) + { + this.apiVersionDescriptionProvider = apiVersionDescriptionProvider; + endpoints = new( endpointDataSource ); + actions = new( actionDescriptorCollectionProvider ); + } + + public IReadOnlyList Items + { + get + { + if ( items is not null && version == CurrentVersion ) + { + return items; + } + + lock ( syncRoot ) + { + var (items1, version1) = endpoints; + var (items2, version2) = actions; + var currentVersion = ComputeVersion( version1, version2 ); + + if ( items is not null && version == currentVersion ) + { + return items; + } + + var capacity = items1.Count + items2.Count; + var metadata = new List( capacity ); + + for ( var i = 0; i < items1.Count; i++ ) + { + metadata.Add( items1[i] ); + } + + for ( var i = 0; i < items2.Count; i++ ) + { + metadata.Add( items2[i] ); + } + + items = apiVersionDescriptionProvider.Describe( metadata ); + version = currentVersion; + } + + return items; + } + } + + private long CurrentVersion + { + get + { + lock ( syncRoot ) + { + return ComputeVersion( endpoints.Version, actions.Version ); + } + } + } + + private static long ComputeVersion( int version1, int version2 ) => ( ( (long) version1 ) << 32 ) | (long) version2; + } + + private sealed class EndpointApiVersionMetadataCollection + { + private readonly object syncRoot = new(); + private readonly EndpointDataSource endpointDataSource; + private List? list; + private int version; + private int currentVersion; + + public EndpointApiVersionMetadataCollection( EndpointDataSource endpointDataSource ) + { + this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) ); + ChangeToken.OnChange( endpointDataSource.GetChangeToken, IncrementVersion ); + } + + public int Version => version; + + public IReadOnlyList Items + { + get + { + if ( list is not null && version == currentVersion ) + { + return list; + } + + lock ( syncRoot ) + { + if ( list is not null && version == currentVersion ) + { + return list; + } + + var endpoints = endpointDataSource.Endpoints; + + if ( list == null ) + { + list = new( capacity: endpoints.Count ); + } + else + { + list.Clear(); + list.Capacity = endpoints.Count; + } + + for ( var i = 0; i < endpoints.Count; i++ ) + { + var metadata = endpoints[i].Metadata; + + if ( metadata.GetMetadata() is ApiVersionMetadata item ) + { +#if NETCOREAPP3_1 + // this code path doesn't appear to exist for netcoreapp3.1 + // REF: https://github.com/dotnet/aspnetcore/blob/release/3.1/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs#L74 + list.Add( new( default, item ) ); +#else + var groupName = metadata.OfType().LastOrDefault()?.EndpointGroupName; + list.Add( new( groupName, item ) ); +#endif + } + } + + version = currentVersion; + } + + return list; + } + } + + public void Deconstruct( out IReadOnlyList items, out int version ) + { + lock ( syncRoot ) + { + version = this.version; + items = Items; + } + } + + private void IncrementVersion() + { + lock ( syncRoot ) + { + currentVersion++; + } + } + } + + private sealed class ActionApiVersionMetadataCollection + { + private readonly object syncRoot = new(); + private readonly IActionDescriptorCollectionProvider provider; + private List? list; + private int version; + + public ActionApiVersionMetadataCollection( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) => + provider = actionDescriptorCollectionProvider ?? throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) ); + + public int Version => version; + + public IReadOnlyList Items + { + get + { + var collection = provider.ActionDescriptors; + + if ( list is not null && collection.Version == version ) + { + return list; + } + + lock ( syncRoot ) + { + if ( list is not null && collection.Version == version ) + { + return list; + } + + var actions = collection.Items; + + if ( list == null ) + { + list = new( capacity: actions.Count ); + } + else + { + list.Clear(); + list.Capacity = actions.Count; + } + + for ( var i = 0; i < actions.Count; i++ ) + { + var action = actions[i]; + list.Add( new( GetGroupName( action ), action.GetApiVersionMetadata() ) ); + } + + version = collection.Version; + } + + return list; + } + } + + // REF: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs + private static string? GetGroupName( ActionDescriptor action ) + { +#if NETCOREAPP3_1 + return action.GetProperty()?.GroupName; +#else + var endpointGroupName = action.EndpointMetadata.OfType().LastOrDefault(); + + if ( endpointGroupName is null ) + { + return action.GetProperty()?.GroupName; + } + + return endpointGroupName.EndpointGroupName; +#endif + } + + public void Deconstruct( out IReadOnlyList items, out int version ) + { + lock ( syncRoot ) + { + version = this.version; + items = Items; + } + } + } + + private sealed class ApiVersionDescriptionComparer : IComparer + { + public int Compare( ApiVersionDescription? x, ApiVersionDescription? y ) + { + if ( x is null ) + { + return y is null ? 0 : -1; + } + + if ( y is null ) + { + return 1; + } + + var result = x.ApiVersion.CompareTo( y.ApiVersion ); + + if ( result == 0 ) + { + result = StringComparer.Ordinal.Compare( x.GroupName, y.GroupName ); + } + + return result; + } + } + + /// + /// Represents the API version metadata applied to an endpoint with an optional group name. + /// + protected class GroupedApiVersionMetadata : ApiVersionMetadata, IEquatable + { + /// + /// Initializes a new instance of the class. + /// + /// The associated group name. + /// The existing metadata to initialize from. + public GroupedApiVersionMetadata( string? groupName, ApiVersionMetadata metadata ) + : base( metadata ) => GroupName = groupName; + + /// + /// Gets the associated group name. + /// + /// The associated group name, if any. + public string? GroupName { get; } + + /// + public bool Equals( GroupedApiVersionMetadata? other ) => other is not null && other.GetHashCode() == GetHashCode(); + + /// + public override bool Equals( object? obj ) => obj is GroupedApiVersionMetadata other && Equals( other ); + + /// + public override int GetHashCode() + { + var hash = default( HashCode ); + + if ( !string.IsNullOrEmpty( GroupName ) ) + { + hash.Add( GroupName, StringComparer.Ordinal ); + } + + hash.Add( base.GetHashCode() ); + + return hash.ToHashCode(); + } + } + + private record struct GroupedApiVersion( string? GroupName, ApiVersion ApiVersion ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/IEndpointRouteBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/IEndpointRouteBuilderExtensions.cs index 5ef252f5..545278fd 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/IEndpointRouteBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/IEndpointRouteBuilderExtensions.cs @@ -2,12 +2,9 @@ namespace Microsoft.AspNetCore.Builder; -using Asp.Versioning; using Asp.Versioning.ApiExplorer; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; /// /// Provides extension methods for . @@ -28,19 +25,10 @@ public static IReadOnlyList DescribeApiVersions( this IEn throw new ArgumentNullException( nameof( endpoints ) ); } - // this should be produced by IApiVersionDescriptionProvider via di; however, for minimal apis, the - // endpoints in the registered EndpointDataSource may not have been built yet. this is important - // for the api explorer extensions (ex: openapi). the following is the same setup that would occur - // through via di, but the IEndpointRouteBuilder is expected to be the WebApplication used during - // setup. unfortunately, the behavior cannot simply be changed by replacing IApiVersionDescriptionProvider - // in the container for minimal apis, but that is not a common scenario. all the types and pieces - // necessary to change this behavior is still possible outside of this method, but it's on the developer var services = endpoints.ServiceProvider; var source = new CompositeEndpointDataSource( endpoints.DataSources ); - var actions = services.GetRequiredService(); - var policyManager = services.GetRequiredService(); - var options = services.GetRequiredService>(); - var provider = new DefaultApiVersionDescriptionProvider( source, actions, policyManager, options ); + var factory = services.GetRequiredService(); + var provider = factory.Create( source ); return provider.ApiVersionDescriptions; } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs index 94163f4d..0fda8310 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs @@ -124,10 +124,11 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) var groupResults = new List( capacity: results.Count ); var unversioned = default( List ); + var formatGroupName = Options.FormatGroupName; foreach ( var version in FlattenApiVersions( results ) ) { - var groupName = version.ToString( Options.GroupNameFormat, CurrentCulture ); + var formattedVersion = version.ToString( Options.GroupNameFormat, CurrentCulture ); for ( var i = 0; i < results.Count; i++ ) { @@ -150,9 +151,13 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) var groupResult = result.Clone(); var metadata = action.GetApiVersionMetadata(); - if ( string.IsNullOrEmpty( groupResult.GroupName ) ) + if ( string.IsNullOrEmpty( groupResult.GroupName ) || formatGroupName is null ) { - groupResult.GroupName = groupName; + groupResult.GroupName = formattedVersion; + } + else + { + groupResult.GroupName = formatGroupName( groupResult.GroupName, formattedVersion ); } if ( SunsetPolicyManager.TryResolvePolicy( metadata.Name, version, out var policy ) ) From 8a12ae7551651765e303c7ae9a0f65821a5d284e Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 6 Nov 2022 21:56:40 -0800 Subject: [PATCH 11/15] Add support for exploring OData metadata routes. Resolves #893 --- .../ApiExplorer/ODataApiExplorer.cs | 23 +++++++- .../Routing/ODataRouteBuilder.cs | 19 +++++++ .../Routing/ODataRouteBuilderContext.cs | 7 ++- .../Description/ODataApiExplorerTest.cs | 41 +++++++++++++- .../Description/TestConfigurations.cs | 14 +++-- .../ODataApiDescriptionProvider.cs | 22 +++++++- .../VersionedMetadataController.cs | 1 + .../ODataApiDescriptionProviderTest.cs | 56 +++++++++++++++++++ .../ApiExplorer/ODataApiExplorerOptions.cs | 6 ++ .../ApiExplorer/ODataMetadataOptions.cs | 30 ++++++++++ src/Common/src/Common.OData/TypeExtensions.cs | 2 +- 11 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataMetadataOptions.cs diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs index 04fec7c6..f6fda7f9 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs @@ -82,6 +82,24 @@ protected override bool ShouldExploreAction( return base.ShouldExploreAction( actionRouteParameterValue, actionDescriptor, route, apiVersion ); } + if ( actionDescriptor.ControllerDescriptor.ControllerType.IsMetadataController() ) + { + if ( actionDescriptor.ActionName == nameof( MetadataController.GetServiceDocument ) ) + { + if ( !Options.MetadataOptions.HasFlag( ODataMetadataOptions.ServiceDocument ) ) + { + return false; + } + } + else + { + if ( !Options.MetadataOptions.HasFlag( ODataMetadataOptions.Metadata ) ) + { + return false; + } + } + } + if ( Options.UseApiExplorerSettings ) { var setting = actionDescriptor.GetCustomAttributes().FirstOrDefault(); @@ -112,9 +130,10 @@ protected override bool ShouldExploreController( throw new ArgumentNullException( nameof( route ) ); } - if ( typeof( MetadataController ).IsAssignableFrom( controllerDescriptor.ControllerType ) ) + if ( controllerDescriptor.ControllerType.IsMetadataController() ) { - return false; + controllerDescriptor.ControllerName = "OData"; + return Options.MetadataOptions > ODataMetadataOptions.None; } var routeTemplate = route.RouteTemplate; diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilder.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilder.cs index ec7368ab..0e9b49f4 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilder.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilder.cs @@ -285,6 +285,25 @@ private void AppendPathFromConventions( IList segments, string controlle case UnboundOperation: builder.Append( Context.Operation!.Name ); AppendParametersFromConvention( builder, Context.Operation ); + break; + default: + var action = Context.ActionDescriptor; + + if ( action.ControllerDescriptor.ControllerType.IsMetadataController() ) + { + if ( action.ActionName == nameof( MetadataController.GetServiceDocument ) ) + { + if ( segments.Count == 0 ) + { + segments.Add( "/" ); + } + } + else + { + segments.Add( "$metadata" ); + } + } + break; } diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilderContext.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilderContext.cs index f38e8bfd..69c2eb45 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilderContext.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Routing/ODataRouteBuilderContext.cs @@ -43,7 +43,9 @@ internal ODataRouteBuilderContext( ActionDescriptor = actionDescriptor; ParameterDescriptions = parameterDescriptions; Options = options; - UrlKeyDelimiter = UrlKeyDelimiterOrDefault( configuration.GetUrlKeyDelimiter() ?? Services.GetService()?.UrlKeyDelimiter ); + UrlKeyDelimiter = UrlKeyDelimiterOrDefault( + configuration.GetUrlKeyDelimiter() ?? + Services.GetService()?.UrlKeyDelimiter ); var selector = Services.GetRequiredService(); var model = selector.SelectModel( apiVersion ); @@ -64,7 +66,8 @@ internal ODataRouteBuilderContext( Singleton = container.FindSingleton( controllerName ); Operation = ResolveOperation( container, actionDescriptor ); ActionType = GetActionType( actionDescriptor ); - IsRouteExcluded = ActionType == ODataRouteActionType.Unknown; + IsRouteExcluded = ActionType == ODataRouteActionType.Unknown && + !actionDescriptor.ControllerDescriptor.ControllerType.IsMetadataController(); if ( Operation?.IsAction() == true ) { diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs index ae6326eb..534d10a3 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs @@ -34,7 +34,10 @@ public void api_descriptions_should_group_versioned_controllers( HttpConfigurati { // arrange var assembliesResolver = configuration.Services.GetAssembliesResolver(); - var controllerTypes = configuration.Services.GetHttpControllerTypeResolver().GetControllerTypes( assembliesResolver ); + var controllerTypes = configuration.Services + .GetHttpControllerTypeResolver() + .GetControllerTypes( assembliesResolver ) + .Where( t => !typeof( MetadataController ).IsAssignableFrom( t ) ); var apiExplorer = new ODataApiExplorer( configuration ); // act @@ -54,7 +57,10 @@ public void api_descriptions_should_flatten_versioned_controllers( HttpConfigura { // arrange var assembliesResolver = configuration.Services.GetAssembliesResolver(); - var controllerTypes = configuration.Services.GetHttpControllerTypeResolver().GetControllerTypes( assembliesResolver ); + var controllerTypes = configuration.Services + .GetHttpControllerTypeResolver() + .GetControllerTypes( assembliesResolver ) + .Where( t => !typeof( MetadataController ).IsAssignableFrom( t ) ); var apiExplorer = new ODataApiExplorer( configuration ); // act @@ -86,6 +92,37 @@ public void api_descriptions_should_not_contain_metadata_controllers( HttpConfig .NotContain( type => typeof( MetadataController ).IsAssignableFrom( type ) ); } + [Theory] + [InlineData( ODataMetadataOptions.ServiceDocument )] + [InlineData( ODataMetadataOptions.Metadata )] + [InlineData( ODataMetadataOptions.All )] + public void api_descriptions_should_contain_metadata_controllers( ODataMetadataOptions metadataOptions ) + { + // arrange + var configuration = TestConfigurations.NewOrdersConfiguration(); + var options = new ODataApiExplorerOptions( configuration ) { MetadataOptions = metadataOptions }; + var apiExplorer = new ODataApiExplorer( configuration, options ); + + // act + var groups = apiExplorer.ApiDescriptions; + + // assert + for ( var i = 0; i < groups.Count; i++ ) + { + var group = groups[i]; + + if ( metadataOptions.HasFlag( ODataMetadataOptions.ServiceDocument ) ) + { + group.ApiDescriptions.Should().Contain( item => item.RelativePath == "api" ); + } + + if ( metadataOptions.HasFlag( ODataMetadataOptions.Metadata ) ) + { + group.ApiDescriptions.Should().Contain( item => item.RelativePath == "api/$metadata" ); + } + } + } + [Theory] [ClassData( typeof( TestConfigurations ) )] public void api_description_group_should_explore_v3_actions( HttpConfiguration configuration ) diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/TestConfigurations.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/TestConfigurations.cs index 87532b08..5e705df8 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/TestConfigurations.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/TestConfigurations.cs @@ -2,6 +2,7 @@ namespace Asp.Versioning.Description; +using Asp.Versioning.Controllers; using Asp.Versioning.Conventions; using Asp.Versioning.OData; using Asp.Versioning.Simulators.Configuration; @@ -24,6 +25,7 @@ public static HttpConfiguration NewOrdersConfiguration() { var configuration = new HttpConfiguration(); var controllerTypeResolver = new ControllerTypeCollection( + typeof( VersionedMetadataController ), typeof( Simulators.V1.OrdersController ), typeof( Simulators.V2.OrdersController ), typeof( Simulators.V3.OrdersController ) ); @@ -57,9 +59,10 @@ public static HttpConfiguration NewPeopleConfiguration() { var configuration = new HttpConfiguration(); var controllerTypeResolver = new ControllerTypeCollection( - typeof( Simulators.V1.PeopleController ), - typeof( Simulators.V2.PeopleController ), - typeof( Simulators.V3.PeopleController ) ); + typeof( VersionedMetadataController ), + typeof( Simulators.V1.PeopleController ), + typeof( Simulators.V2.PeopleController ), + typeof( Simulators.V3.PeopleController ) ); configuration.Services.Replace( typeof( IHttpControllerTypeResolver ), controllerTypeResolver ); configuration.AddApiVersioning(); @@ -79,8 +82,9 @@ public static HttpConfiguration NewProductAndSupplierConfiguration() { var configuration = new HttpConfiguration(); var controllerTypeResolver = new ControllerTypeCollection( - typeof( Simulators.V3.ProductsController ), - typeof( Simulators.V3.SuppliersController ) ); + typeof( VersionedMetadataController ), + typeof( Simulators.V3.ProductsController ), + typeof( Simulators.V3.SuppliersController ) ); configuration.Services.Replace( typeof( IHttpControllerTypeResolver ), controllerTypeResolver ); configuration.AddApiVersioning(); diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs index 3feb6754..0084cd01 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs @@ -16,6 +16,7 @@ namespace Asp.Versioning.ApiExplorer; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using static System.StringComparison; +using static ODataMetadataOptions; using Opts = Microsoft.Extensions.Options.Options; /// @@ -118,11 +119,24 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) } if ( !TryMatchModelVersion( result, metadata, out var matched ) || - IsServiceDocumentOrMetadata( matched.Template ) || !visited.Add( result ) ) { results.RemoveAt( i ); } + else if ( IsServiceDocument( matched.Template ) ) + { + if ( !Options.MetadataOptions.HasFlag( ServiceDocument ) ) + { + results.RemoveAt( i ); + } + } + else if ( IsMetadata( matched.Template ) ) + { + if ( !Options.MetadataOptions.HasFlag( Metadata ) ) + { + results.RemoveAt( i ); + } + } else if ( IsNavigationPropertyLink( matched.Template ) ) { results.RemoveAt( i ); @@ -176,8 +190,10 @@ private static int ApiVersioningOrder() } [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static bool IsServiceDocumentOrMetadata( ODataPathTemplate template ) => - template.Count == 0 || ( template.Count == 1 && template[0] is MetadataSegmentTemplate ); + private static bool IsServiceDocument( ODataPathTemplate template ) => template.Count == 0; + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static bool IsMetadata( ODataPathTemplate template ) => template.Count == 1 && template[0] is MetadataSegmentTemplate; [MethodImpl( MethodImplOptions.AggressiveInlining )] private static bool IsNavigationPropertyLink( ODataPathTemplate template ) => diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/Controllers/VersionedMetadataController.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/Controllers/VersionedMetadataController.cs index 1e78d72d..e29e2ede 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/Controllers/VersionedMetadataController.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/Controllers/VersionedMetadataController.cs @@ -14,6 +14,7 @@ namespace Asp.Versioning.Controllers; /// [CLSCompliant( false )] [ReportApiVersions] +[ControllerName( "OData" )] public class VersionedMetadataController : MetadataController { /// diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs index 3f5f3477..3c3f3136 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs @@ -56,6 +56,62 @@ public void odata_api_explorer_should_group_and_order_descriptions_on_providers_ AssertVersion3( groups[3] ); } + [Theory] + [InlineData( ODataMetadataOptions.ServiceDocument )] + [InlineData( ODataMetadataOptions.Metadata )] + [InlineData( ODataMetadataOptions.All )] + public void odata_api_explorer_should_explore_metadata_routes( ODataMetadataOptions metadataOptions ) + { + // arrange + var builder = new WebHostBuilder() + .ConfigureServices( + services => + { + services.AddControllers() + .AddOData( + options => + { + options.Count().Select().OrderBy(); + options.RouteOptions.EnableKeyInParenthesis = false; + options.RouteOptions.EnableNonParenthesisForEmptyParameterFunction = true; + options.RouteOptions.EnableQualifiedOperationCall = false; + options.RouteOptions.EnableUnqualifiedOperationCall = true; + } ); + + services.AddApiVersioning() + .AddOData( options => options.AddRouteComponents( "api" ) ) + .AddODataApiExplorer( options => options.MetadataOptions = metadataOptions ); + + services.TryAddEnumerable( ServiceDescriptor.Transient() ); + } ) + .Configure( app => app.UseRouting().UseEndpoints( endpoints => endpoints.MapControllers() ) ); + var host = builder.Build(); + var serviceProvider = host.Services; + + // act + var groups = serviceProvider.GetRequiredService() + .ApiDescriptionGroups + .Items + .OrderBy( i => i.GroupName ) + .ToArray(); + + // assert + for ( var i = 0; i < groups.Length; i++ ) + { + var group = groups[i]; + + if ( metadataOptions.HasFlag( ODataMetadataOptions.ServiceDocument ) ) + { + group.Items.Should().Contain( item => item.RelativePath == "api" ); + } + + if ( metadataOptions.HasFlag( ODataMetadataOptions.Metadata ) ) + { + group.Items.Should().Contain( item => item.RelativePath == "api/$metadata" ); + } + } + } + private readonly ITestOutputHelper console; public ODataApiDescriptionProviderTest( ITestOutputHelper console ) => this.console = console; diff --git a/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs b/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs index 7323edad..c90cafb5 100644 --- a/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs +++ b/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs @@ -38,4 +38,10 @@ public ODataQueryOptionsConventionBuilder QueryOptions get => queryOptions ??= new(); set => queryOptions = value; } + + /// + /// Gets or sets the OData metadata options used during API exploration. + /// + /// One or more values. + public ODataMetadataOptions MetadataOptions { get; set; } = ODataMetadataOptions.None; } \ No newline at end of file diff --git a/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataMetadataOptions.cs b/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataMetadataOptions.cs new file mode 100644 index 00000000..315d7a4b --- /dev/null +++ b/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataMetadataOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +/// +/// Represents the possible OData metadata options used during API exploration. +/// +[Flags] +public enum ODataMetadataOptions +{ + /// + /// Indicates no OData metadata options. + /// + None = 0, + + /// + /// Indicates the OData service document will be included. + /// + ServiceDocument = 1, + + /// + /// Indicates the OData metadata document will be included. + /// + Metadata = 2, + + /// + /// Indicates all OData metadata options. + /// + All = ServiceDocument | Metadata, +} \ No newline at end of file diff --git a/src/Common/src/Common.OData/TypeExtensions.cs b/src/Common/src/Common.OData/TypeExtensions.cs index 10d70708..6596cbfb 100644 --- a/src/Common/src/Common.OData/TypeExtensions.cs +++ b/src/Common/src/Common.OData/TypeExtensions.cs @@ -28,7 +28,7 @@ internal static partial class TypeExtensions internal static bool IsODataController( this Type controllerType ) => controllerType.UsingOData(); - internal static bool IsMetadataController( this TypeInfo controllerType ) + internal static bool IsMetadataController( this Type controllerType ) { metadataController ??= typeof( MetadataController ); return metadataController.IsAssignableFrom( controllerType ); From 4b9275cd80dc64e45907c384f08b73e7c56d457b Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 6 Nov 2022 22:26:38 -0800 Subject: [PATCH 12/15] Add missing tests --- ...roupedApiVersionDescriptionProviderTest.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs new file mode 100644 index 00000000..ba8630d0 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; + +public class GroupedApiVersionDescriptionProviderTest +{ + [Fact] + public void api_version_descriptions_should_collate_expected_versions() + { + // arrange + var descriptionProvider = new GroupedApiVersionDescriptionProvider( + new TestEndpointDataSource(), + new TestActionDescriptorCollectionProvider(), + Mock.Of(), + Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); + + // act + var descriptions = descriptionProvider.ApiVersionDescriptions; + + // assert + descriptions.Should().BeEquivalentTo( + new ApiVersionDescription[] + { + new( new ApiVersion( 0, 9 ), "v0.9", true ), + new( new ApiVersion( 1, 0 ), "v1", false ), + new( new ApiVersion( 2, 0 ), "v2", false ), + new( new ApiVersion( 3, 0 ), "v3", false ), + } ); + } + + [Fact] + public void api_version_descriptions_should_collate_expected_versions_with_custom_group() + { + // arrange + var provider = new TestActionDescriptorCollectionProvider(); + var source = new CompositeEndpointDataSource( Enumerable.Empty() ); + var data = new ApiDescriptionActionData() { GroupName = "Test" }; + + foreach ( var descriptor in provider.ActionDescriptors.Items ) + { + descriptor.SetProperty( data ); + } + + var descriptionProvider = new GroupedApiVersionDescriptionProvider( + source, + provider, + Mock.Of(), + Options.Create( + new ApiExplorerOptions() + { + GroupNameFormat = "VVV", + FormatGroupName = ( groupName, version ) => $"{groupName}-{version}", + } ) ); + + // act + var descriptions = descriptionProvider.ApiVersionDescriptions; + + // assert + descriptions.Should().BeEquivalentTo( + new ApiVersionDescription[] + { + new( new ApiVersion( 0, 9 ), "Test-0.9", true ), + new( new ApiVersion( 1, 0 ), "Test-1", false ), + new( new ApiVersion( 2, 0 ), "Test-2", false ), + new( new ApiVersion( 3, 0 ), "Test-3", false ), + } ); + } + + [Fact] + public void api_version_descriptions_should_apply_sunset_policy() + { + // arrange + var expected = new SunsetPolicy(); + var apiVersion = new ApiVersion( 0.9 ); + var policyManager = new Mock(); + + policyManager.Setup( pm => pm.TryGetPolicy( default, apiVersion, out expected ) ).Returns( true ); + + var descriptionProvider = new GroupedApiVersionDescriptionProvider( + new TestEndpointDataSource(), + new TestActionDescriptorCollectionProvider(), + policyManager.Object, + Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); + + // act + var description = descriptionProvider.ApiVersionDescriptions.Single( api => api.GroupName == "v0.9" ); + + // assert + description.SunsetPolicy.Should().BeSameAs( expected ); + } +} \ No newline at end of file From 86cafb71db869761563a96d7f1ee03548043c353 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 7 Nov 2022 17:03:06 -0800 Subject: [PATCH 13/15] Bump version and update release notes --- .../Asp.Versioning.Abstractions.csproj | 4 ++-- .../src/Asp.Versioning.Abstractions/ReleaseNotes.txt | 2 +- .../Asp.Versioning.WebApi.OData.ApiExplorer.csproj | 4 ++-- .../Asp.Versioning.WebApi.OData.ApiExplorer/ReleaseNotes.txt | 2 +- .../Asp.Versioning.WebApi.OData.csproj | 4 ++-- .../OData/src/Asp.Versioning.WebApi.OData/ReleaseNotes.txt | 2 +- .../Asp.Versioning.WebApi.ApiExplorer.csproj | 4 ++-- .../src/Asp.Versioning.WebApi.ApiExplorer/ReleaseNotes.txt | 2 +- .../src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj | 4 ++-- src/AspNet/WebApi/src/Asp.Versioning.WebApi/ReleaseNotes.txt | 2 +- .../Asp.Versioning.OData.ApiExplorer.csproj | 4 ++-- .../src/Asp.Versioning.OData.ApiExplorer/ReleaseNotes.txt | 2 +- .../src/Asp.Versioning.OData/Asp.Versioning.OData.csproj | 4 ++-- .../OData/src/Asp.Versioning.OData/ReleaseNotes.txt | 2 +- .../WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj | 4 ++-- .../WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt | 4 +--- .../Asp.Versioning.Mvc.ApiExplorer.csproj | 4 ++-- .../src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt | 2 +- .../WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj | 4 ++-- src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt | 2 +- 20 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj b/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj index 27c3e69e..cf2411e7 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Asp.Versioning.Abstractions.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 netstandard1.0;netstandard2.0;net6.0 API Versioning Abstractions The abstractions library for API versioning. diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt b/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt index 027707da..5f282702 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ReleaseNotes.txt @@ -1 +1 @@ -Added ApiVersion copy constructor \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj index c0fa339d..0465c87e 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Asp.Versioning.WebApi.OData.ApiExplorer.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net45;net472 Asp.Versioning ASP.NET Web API Versioning API Explorer for OData v4.0 diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ReleaseNotes.txt b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ReleaseNotes.txt index a8fd0012..5f282702 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ReleaseNotes.txt +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ReleaseNotes.txt @@ -1 +1 @@ -Update OData query option exploration (#702, #853) \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj index b0d9155f..8dfc8a6f 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/Asp.Versioning.WebApi.OData.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net45;net472 Asp.Versioning API Versioning for ASP.NET Web API with OData v4.0 diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/ReleaseNotes.txt b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/ReleaseNotes.txt index dac19fe7..5f282702 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/ReleaseNotes.txt +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/ReleaseNotes.txt @@ -1 +1 @@ -Minor version bump \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj index 76389104..fd35f31a 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Asp.Versioning.WebApi.ApiExplorer.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net45;net472 ASP.NET Web API Versioning API Explorer The API Explorer extensions for ASP.NET Web API Versioning. diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ReleaseNotes.txt b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ReleaseNotes.txt index dac19fe7..5f282702 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ReleaseNotes.txt +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ReleaseNotes.txt @@ -1 +1 @@ -Minor version bump \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj index f28daa22..ee683f0f 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net45;net472 ASP.NET Web API Versioning A service API versioning library for Microsoft ASP.NET Web API. diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/ReleaseNotes.txt b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/ReleaseNotes.txt index c3d8d5a3..5f282702 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/ReleaseNotes.txt +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/ReleaseNotes.txt @@ -1 +1 @@ -Support custom reporting HTTP headers (#875) \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj index 6c6fcca0..16759695 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning API Explorer for OData v4.0 diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ReleaseNotes.txt b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ReleaseNotes.txt index a8fd0012..5f282702 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ReleaseNotes.txt +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ReleaseNotes.txt @@ -1 +1 @@ -Update OData query option exploration (#702, #853) \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj b/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj index 8661317d..16d4b375 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning with OData v4.0 diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt b/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt index f45b0196..5f282702 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt @@ -1 +1 @@ -Support batching (#720, #847) \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj index fdd1b025..6e440ad7 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt index 75fe6185..5f282702 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt @@ -1,3 +1 @@ -Enable binding ApiVersion in Minimal APIs -Support custom reporting HTTP headers (#875) -Fix matching precedence of overlapping endpoint templates (#884) \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj index 429bbac2..4212eaad 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net6.0;netcoreapp3.1 Asp.Versioning.ApiExplorer ASP.NET Core API Versioning API Explorer diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt index dac19fe7..5f282702 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt @@ -1 +1 @@ -Minor version bump \ No newline at end of file + \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj index 0ae7898a..597fc408 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj @@ -1,8 +1,8 @@  - 6.1.0 - 6.1.0.0 + 6.2.0 + 6.2.0.0 net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt index dac19fe7..5f282702 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt @@ -1 +1 @@ -Minor version bump \ No newline at end of file + \ No newline at end of file From 5f79843853aa5e764f1da66321fd365d32a54a85 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 7 Nov 2022 17:45:16 -0800 Subject: [PATCH 14/15] Fix CodeQL violations --- .../MediaTypeApiVersionReaderBuilder.cs | 8 ++++---- .../GroupedApiVersionDescriptionProvider.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs index 4f94837e..11f0fcb3 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs @@ -33,11 +33,11 @@ public partial class MediaTypeApiVersionReaderBuilder { var parser = new RouteParser(); var parsedRoute = parser.Parse( template ); - var parameters = from content in parsedRoute.PathSegments.OfType() - from parameter in content.Subsegments.OfType() - select parameter; + var segments = from content in parsedRoute.PathSegments.OfType() + from segment in content.Subsegments.OfType() + select segment; - if ( parameters.Count() > 1 ) + if ( segments.Count() > 1 ) { var message = string.Format( CultureInfo.CurrentCulture, CommonSR.InvalidMediaTypeTemplate, template ); throw new ArgumentException( message, nameof( template ) ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs index 7e53d9f3..c8b53796 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs @@ -444,7 +444,7 @@ public GroupedApiVersionMetadata( string? groupName, ApiVersionMetadata metadata public bool Equals( GroupedApiVersionMetadata? other ) => other is not null && other.GetHashCode() == GetHashCode(); /// - public override bool Equals( object? obj ) => obj is GroupedApiVersionMetadata other && Equals( other ); + public override bool Equals( object? obj ) => Equals( obj as GroupedApiVersionMetadata ); /// public override int GetHashCode() From 733b323bcb34aa2ca782dbe5da2b3c5eccd47e2c Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 7 Nov 2022 18:10:46 -0800 Subject: [PATCH 15/15] CodeQL fix --- .../GroupedApiVersionDescriptionProvider.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs index c8b53796..d15d45fb 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs @@ -441,10 +441,14 @@ public GroupedApiVersionMetadata( string? groupName, ApiVersionMetadata metadata public string? GroupName { get; } /// - public bool Equals( GroupedApiVersionMetadata? other ) => other is not null && other.GetHashCode() == GetHashCode(); + public bool Equals( GroupedApiVersionMetadata? other ) => + other is not null && other.GetHashCode() == GetHashCode(); /// - public override bool Equals( object? obj ) => Equals( obj as GroupedApiVersionMetadata ); + public override bool Equals( object? obj ) => + obj is not null && + GetType().Equals( obj.GetType() ) && + GetHashCode() == obj.GetHashCode(); /// public override int GetHashCode()