From 5250465df99680e9442fc0fd4f0c2466cb69b3ec Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 4 Nov 2022 18:37:09 -0700 Subject: [PATCH] Add complex media type API version reader support. Resolves #887 --- .../MediaTypeApiVersionReaderBuilder.cs | 123 +++++ .../MediaTypeApiVersionReaderBuilderTest.cs | 347 ++++++++++++ .../MediaTypeApiVersionReaderBuilder.cs | 114 ++++ .../MediaTypeApiVersionBuilderTest.cs | 337 ++++++++++++ .../MediaTypeApiVersionReaderTest.cs | 17 + src/Common/src/Common/CommonSR.Designer.cs | 11 + src/Common/src/Common/CommonSR.resx | 3 + .../MediaTypeApiVersionReaderBuilder.cs | 518 ++++++++++++++++++ ...iaTypeApiVersionReaderBuilderExtensions.cs | 27 + 9 files changed, 1497 insertions(+) create mode 100644 src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs create mode 100644 src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs create mode 100644 src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs create mode 100644 src/Common/src/Common/MediaTypeApiVersionReaderBuilderExtensions.cs diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs new file mode 100644 index 00000000..4f94837e --- /dev/null +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/MediaTypeApiVersionReaderBuilder.cs @@ -0,0 +1,123 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Asp.Versioning.Routing; +using System.Globalization; +using System.Net.Http.Headers; +using System.Web.Http.Routing; + +/// +/// Provides additional implementation specific to ASP.NET Web API. +/// +public partial class MediaTypeApiVersionReaderBuilder +{ + /// + /// Adds a template used to read an API version from a media type. + /// + /// The template used to match the media type. + /// The optional name of the API version parameter in the template. + /// If a value is not specified, there is expected to be a single template parameter. + /// The current . + /// The template syntax is the same used by route templates; however, constraints are not supported. +#pragma warning disable CA1716 // Identifiers should not match keywords + public virtual MediaTypeApiVersionReaderBuilder Template( string template, string? parameterName = default ) +#pragma warning restore CA1716 // Identifiers should not match keywords + { + if ( string.IsNullOrEmpty( template ) ) + { + throw new ArgumentNullException( nameof( template ) ); + } + + if ( string.IsNullOrEmpty( parameterName ) ) + { + var parser = new RouteParser(); + var parsedRoute = parser.Parse( template ); + var parameters = from content in parsedRoute.PathSegments.OfType() + from parameter in content.Subsegments.OfType() + select parameter; + + if ( parameters.Count() > 1 ) + { + var message = string.Format( CultureInfo.CurrentCulture, CommonSR.InvalidMediaTypeTemplate, template ); + throw new ArgumentException( message, nameof( template ) ); + } + } + + var route = new HttpRoute( template ); + + AddReader( mediaTypes => ReadMediaTypePattern( mediaTypes, route, parameterName ) ); + + return this; + } + + private static IReadOnlyList ReadMediaTypePattern( + IReadOnlyList mediaTypes, + HttpRoute route, + string? parameterName ) + { + var assumeOneParameter = string.IsNullOrEmpty( parameterName ); + var version = default( string ); + var versions = default( List ); + using var request = new HttpRequestMessage(); + + for ( var i = 0; i < mediaTypes.Count; i++ ) + { + var mediaType = mediaTypes[i].MediaType; + request.RequestUri = new Uri( "http://localhost/" + mediaType ); + var data = route.GetRouteData( string.Empty, request ); + + if ( data == null ) + { + continue; + } + + var values = data.Values; + + if ( values.Count == 0 ) + { + continue; + } + + object datum; + + if ( assumeOneParameter ) + { + datum = values.Values.First(); + } + else if ( !values.TryGetValue( parameterName, out datum ) ) + { + continue; + } + + if ( datum is not string value || string.IsNullOrEmpty( value ) ) + { + continue; + } + + if ( version == null ) + { + version = value; + } + else if ( versions == null ) + { + versions = new( capacity: mediaTypes.Count - i + 1 ) + { + version, + value, + }; + } + else + { + versions.Add( value ); + } + } + + if ( version is null ) + { + return Array.Empty(); + } + + return versions is null ? new[] { version } : versions.ToArray(); + } +} \ No newline at end of file diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs new file mode 100644 index 00000000..0bd66223 --- /dev/null +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/MediaTypeApiVersionReaderBuilderTest.cs @@ -0,0 +1,347 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using System.Net.Http; +using System.Web; +using System.Web.UI; +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" ) + .Choose( versions => versions.Count == 0 ? versions : new[] { versions[0] } ) + .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 add_parameters_should_add_parameter_for_media_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var context = new Mock(); + + context.Setup( c => c.AddParameter( It.IsAny(), It.IsAny() ) ); + + // act + reader.AddParameters( context.Object ); + + // assert + context.Verify( c => c.AddParameter( "v", MediaTypeParameter ), Times.Once() ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs new file mode 100644 index 00000000..45655770 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/MediaTypeApiVersionReaderBuilder.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Routing.Template; +using Microsoft.Net.Http.Headers; +using System.Globalization; + +/// +/// Provides additional implementation specific to ASP.NET Core. +/// +public partial class MediaTypeApiVersionReaderBuilder +{ + /// + /// Adds a template used to read an API version from a media type. + /// + /// The template used to match the media type. + /// The optional name of the API version parameter in the template. + /// If a value is not specified, there is expected to be a single template parameter. + /// The current . + /// The template syntax is the same used by route templates; however, constraints are not supported. +#pragma warning disable CA1716 // Identifiers should not match keywords + public virtual MediaTypeApiVersionReaderBuilder Template( string template, string? parameterName = default ) +#pragma warning restore CA1716 // Identifiers should not match keywords + { + if ( string.IsNullOrEmpty( template ) ) + { + throw new ArgumentNullException( nameof( template ) ); + } + + var routePattern = RoutePatternFactory.Parse( template ); + + if ( string.IsNullOrEmpty( parameterName ) && routePattern.Parameters.Count > 1 ) + { + var message = string.Format( CultureInfo.CurrentCulture, CommonSR.InvalidMediaTypeTemplate, template ); + throw new ArgumentException( message, nameof( template ) ); + } + + var defaults = new RouteValueDictionary( routePattern.RequiredValues ); + var matcher = new TemplateMatcher( new( routePattern ), defaults ); + + AddReader( mediaTypes => ReadMediaTypePattern( mediaTypes, matcher, parameterName ) ); + + return this; + } + + private static IReadOnlyList ReadMediaTypePattern( + IReadOnlyList mediaTypes, + TemplateMatcher matcher, + string? parameterName ) + { + const char RequiredPrefix = '/'; + var assumeOneParameter = string.IsNullOrEmpty( parameterName ); + var version = default( string ); + var versions = default( List ); + var values = new RouteValueDictionary(); + + for ( var i = 0; i < mediaTypes.Count; i++ ) + { + var mediaType = mediaTypes[i].MediaType.Value; + var path = new PathString( RequiredPrefix + mediaType ); + + values.Clear(); + + if ( !matcher.TryMatch( path, values ) || values.Count == 0 ) + { + continue; + } + + object? datum; + + if ( assumeOneParameter ) + { + datum = values.Values.First(); + } + else if ( !values.TryGetValue( parameterName!, out datum ) ) + { + continue; + } + + if ( datum is not string value || string.IsNullOrEmpty( value ) ) + { + continue; + } + + if ( version == null ) + { + version = value; + } + else if ( versions == null ) + { + versions = new( capacity: mediaTypes.Count - i + 1 ) + { + version, + value, + }; + } + else + { + versions.Add( value ); + } + } + + if ( version is null ) + { + return Array.Empty(); + } + + return versions is null ? new[] { version } : versions.ToArray(); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs new file mode 100644 index 00000000..bcf0998b --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionBuilderTest.cs @@ -0,0 +1,337 @@ +// 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" ) + .Choose( versions => versions.Count == 0 ? versions : new[] { versions[0] } ) + .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 add_parameters_should_add_parameter_for_media_type() + { + // arrange + var reader = new MediaTypeApiVersionReaderBuilder().Parameter( "v" ).Build(); + var context = new Mock(); + + context.Setup( c => c.AddParameter( It.IsAny(), It.IsAny() ) ); + + // act + reader.AddParameters( context.Object ); + + // assert + context.Verify( c => c.AddParameter( "v", MediaTypeParameter ), Times.Once() ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs index 8c931741..43b097cc 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/MediaTypeApiVersionReaderTest.cs @@ -4,6 +4,7 @@ namespace Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; +using static ApiVersionParameterLocation; using static System.IO.Stream; public class MediaTypeApiVersionReaderTest @@ -163,4 +164,20 @@ public void read_should_retrieve_version_from_accept_with_custom_parameter() // assert versions.Single().Should().Be( "3.0" ); } + + [Fact] + public void add_parameters_should_add_parameter_for_media_type() + { + // arrange + var reader = new MediaTypeApiVersionReader(); + var context = new Mock(); + + context.Setup( c => c.AddParameter( It.IsAny(), It.IsAny() ) ); + + // act + reader.AddParameters( context.Object ); + + // assert + context.Verify( c => c.AddParameter( "v", MediaTypeParameter ), Times.Once() ); + } } \ No newline at end of file diff --git a/src/Common/src/Common/CommonSR.Designer.cs b/src/Common/src/Common/CommonSR.Designer.cs index 099039cd..bb9844a6 100644 --- a/src/Common/src/Common/CommonSR.Designer.cs +++ b/src/Common/src/Common/CommonSR.Designer.cs @@ -90,5 +90,16 @@ internal static string ZeroApiVersionReaders return ResourceManager.GetString( "ZeroApiVersionReaders", resourceCulture ); } } + + /// + /// Looks up a localized string similar to The template '{0}' has more than one parameter and no parameter name was specified.. + /// + internal static string InvalidMediaTypeTemplate + { + get + { + return ResourceManager.GetString( "InvalidMediaTypeTemplate", resourceCulture ); + } + } } } diff --git a/src/Common/src/Common/CommonSR.resx b/src/Common/src/Common/CommonSR.resx index 842b7e5f..d6f616ee 100644 --- a/src/Common/src/Common/CommonSR.resx +++ b/src/Common/src/Common/CommonSR.resx @@ -126,4 +126,7 @@ At least one IApiVersionReader must be specified. + + The template '{0}' has more than one parameter and no parameter name was specified. + \ No newline at end of file diff --git a/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs b/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs new file mode 100644 index 00000000..0669f349 --- /dev/null +++ b/src/Common/src/Common/MediaTypeApiVersionReaderBuilder.cs @@ -0,0 +1,518 @@ +// 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 readonly IApiVersionParser parser; + private HashSet? parameters; + private HashSet? included; + private HashSet? excluded; + private Func, IReadOnlyList>? selector; + private List, IReadOnlyList>>? readers; + + /// + /// Initializes a new instance of the class. + /// + public MediaTypeApiVersionReaderBuilder() => parser = ApiVersionParser.Default; + + /// + /// Initializes a new instance of the class. + /// + /// The parser used to parse API versions. + /// is null. + public MediaTypeApiVersionReaderBuilder( IApiVersionParser parser ) => + this.parser = parser ?? throw new ArgumentNullException( nameof( parser ) ); + + /// + /// 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; + } + + /// + /// Chooses one or more raw API versions read from media types. + /// + /// The function used to select results. + /// The current . + /// The input list of raw API versions will be sorted according to their precedence. The selector will + /// only be invoked if there is more than one value. + public virtual MediaTypeApiVersionReaderBuilder Choose( Func, IReadOnlyList> choiceSelector ) + { + selector = choiceSelector; + 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( + parser, + parameters?.ToArray() ?? Array.Empty(), + included ?? EmptyCollection(), + excluded ?? EmptyCollection(), + selector ?? 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( 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 IApiVersionParser parser; + private readonly IReadOnlyList parameters; + private readonly ICollection included; + private readonly ICollection excluded; + private readonly Func, IReadOnlyList> selector; + private readonly IReadOnlyList, IReadOnlyList>> readers; + + internal BuiltMediaTypeApiVersionReader( + IApiVersionParser parser, + IReadOnlyList parameters, + ICollection included, + ICollection excluded, + Func, IReadOnlyList> selector, + IReadOnlyList, IReadOnlyList>> readers ) + { + this.parser = parser; + 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() + ";q=1" ) }; +#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( Sort( versions.ToArray() ) ); + } + + private IReadOnlyList Sort( string[] versions ) + { + var count = versions.Length; +#if NETFRAMEWORK + var sorted = new Indexed[count]; +#else + var sorted = ArrayPool.Shared.Rent( count ); +#endif + + for ( var i = 0; i < count; i++ ) + { + if ( !parser.TryParse( versions[i], out var parsed ) ) + { +#if !NETFRAMEWORK + ArrayPool.Shared.Return( sorted ); +#endif + return versions; + } + + sorted[i] = new( i, parsed! ); + } + + System.Array.Sort( sorted, 0, count, TupleComparer.Descending ); + +#if NETFRAMEWORK + var temp = new string[count]; +#else + var temp = ArrayPool.Shared.Rent( count ); +#endif + + for ( var i = 0; i < count; i++ ) + { + temp[i] = versions[sorted[i].Index]; + } + + for ( var i = 0; i < count; i++ ) + { + versions[i] = temp[i]; + } + +#if !NETFRAMEWORK + ArrayPool.Shared.Return( sorted ); + ArrayPool.Shared.Return( temp ); +#endif + + return versions; + } + + 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] ); + } + } + } + } + } + + private readonly struct Indexed + { + public readonly int Index; + public readonly ApiVersion ApiVersion; + + public Indexed( int index, ApiVersion apiVersion ) + { + Index = index; + ApiVersion = apiVersion; + } + } + + private sealed class TupleComparer : IComparer + { + private static TupleComparer? instance; + + private TupleComparer() { } + + public static IComparer Descending => instance ??= new(); + + public int Compare( Indexed x, Indexed y ) => -x.ApiVersion.CompareTo( y.ApiVersion ); + } +} \ 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..14a4f15e --- /dev/null +++ b/src/Common/src/Common/MediaTypeApiVersionReaderBuilderExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Provides extension methods for . +/// +public static class MediaTypeApiVersionReaderBuilderExtensions +{ + /// + /// Chooses the first available API version, if there is one. + /// + /// The type of builder. + /// The extended builder. + /// The current builder. + /// is null. + public static T ChooseFirstOrDefault( this T builder ) where T : MediaTypeApiVersionReaderBuilder + { + if ( builder == null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + builder.Choose( static versions => versions.Count == 0 ? versions : new[] { versions[0] } ); + return builder; + } +} \ No newline at end of file