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.
///
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/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/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/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.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/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/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/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/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/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/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/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs
new file mode 100644
index 00000000..11f0fcb3
--- /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 segments = from content in parsedRoute.PathSegments.OfType()
+ from segment in content.Subsegments.OfType()
+ select segment;
+
+ if ( segments.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/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/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
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/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/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs
index d2c4ed0f..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 ) =>
@@ -188,12 +204,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.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/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/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
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/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/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
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/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/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.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
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/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/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..d15d45fb
--- /dev/null
+++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs
@@ -0,0 +1,470 @@
+// 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 not null &&
+ GetType().Equals( obj.GetType() ) &&
+ GetHashCode() == obj.GetHashCode();
+
+ ///
+ 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/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.ApiExplorer/VersionedApiDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs
index bce9e94c..0fda8310 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 );
}
///
@@ -121,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++ )
{
@@ -147,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 = formattedVersion;
+ }
+ else
{
- groupResult.GroupName = groupName;
+ groupResult.GroupName = formatGroupName( groupResult.GroupName, formattedVersion );
}
if ( SunsetPolicyManager.TryResolvePolicy( metadata.Name, version, out var policy ) )
@@ -159,7 +167,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
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
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/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
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 );
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..ca54d123
--- /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
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