From 64ba30dcfbed2f9c17906c9dfa5219acbc9cdf8f Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 12 Jan 2024 15:16:21 -0800 Subject: [PATCH 1/2] Added IEndpointInspector so that controller action endpoints are not processed by min api endpoint collators. Related #1066 --- .../ApiExplorer/DefaultEndpointInspector.cs | 15 ++++++++ ...ointApiVersionMetadataCollationProvider.cs | 19 ++++++++-- .../ApiExplorer/IEndpointInspector.cs | 19 ++++++++++ .../IServiceCollectionExtensions.cs | 1 + .../ApiVersionDescriptionProviderFactory.cs | 5 ++- .../IApiVersioningBuilderExtensions.cs | 36 +++++++++---------- .../ApiExplorer/MvcEndpointInspector.cs | 21 +++++++++++ .../IApiVersioningBuilderExtensions.cs | 18 ++++++++++ 8 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/DefaultEndpointInspector.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IEndpointInspector.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/MvcEndpointInspector.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/DefaultEndpointInspector.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/DefaultEndpointInspector.cs new file mode 100644 index 00000000..7109d87f --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/DefaultEndpointInspector.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Http; + +/// +/// Represents the default endpoint inspector. +/// +[CLSCompliant(false)] +public sealed class DefaultEndpointInspector : IEndpointInspector +{ + /// + public bool IsControllerAction( Endpoint endpoint ) => false; +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs index 45c14725..e5ce20fb 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs @@ -12,15 +12,29 @@ namespace Asp.Versioning.ApiExplorer; public sealed class EndpointApiVersionMetadataCollationProvider : IApiVersionMetadataCollationProvider { private readonly EndpointDataSource endpointDataSource; + private readonly IEndpointInspector endpointInspector; private int version; /// /// Initializes a new instance of the class. /// /// The underlying endpoint data source. + [Obsolete( "Use the constructor that accepts IEndpointInspector. This constructor will be removed in a future version." )] public EndpointApiVersionMetadataCollationProvider( EndpointDataSource endpointDataSource ) + : this( endpointDataSource, new DefaultEndpointInspector() ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The underlying endpoint data source. + /// The endpoint inspector used to inspect endpoints. + public EndpointApiVersionMetadataCollationProvider( EndpointDataSource endpointDataSource, IEndpointInspector endpointInspector ) { - this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) ); + ArgumentNullException.ThrowIfNull( endpointDataSource ); + ArgumentNullException.ThrowIfNull( endpointInspector ); + + this.endpointDataSource = endpointDataSource; + this.endpointInspector = endpointInspector; ChangeToken.OnChange( endpointDataSource.GetChangeToken, () => ++version ); } @@ -38,7 +52,8 @@ public void Execute( ApiVersionMetadataCollationContext context ) { var endpoint = endpoints[i]; - if ( endpoint.Metadata.GetMetadata() is not ApiVersionMetadata item ) + if ( endpoint.Metadata.GetMetadata() is not ApiVersionMetadata item || + endpointInspector.IsControllerAction( endpoint ) ) { continue; } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IEndpointInspector.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IEndpointInspector.cs new file mode 100644 index 00000000..900edf94 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IEndpointInspector.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Http; + +/// +/// Defines the behavior of an endpoint inspector. +/// +[CLSCompliant( false )] +public interface IEndpointInspector +{ + /// + /// Determines whether the specified endpoint is a controller action. + /// + /// The endpoint to inspect. + /// True if the is for a controller action; otherwise, false. + bool IsControllerAction( Endpoint endpoint ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs index 07911329..14606936 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs @@ -89,6 +89,7 @@ private static void AddApiVersioningServices( IServiceCollection services ) services.TryAddEnumerable( Transient, ApiVersioningRouteOptionsSetup>() ); services.TryAddEnumerable( Singleton() ); services.TryAddEnumerable( Singleton() ); + services.TryAddTransient(); services.Replace( WithLinkGeneratorDecorator( services ) ); TryAddProblemDetailsRfc7231Compliance( services ); TryAddErrorObjectJsonOptions( services ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs index 05b1cba1..75b99644 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs @@ -16,6 +16,7 @@ internal sealed class ApiVersionDescriptionProviderFactory : IApiVersionDescript { private readonly ISunsetPolicyManager sunsetPolicyManager; private readonly IApiVersionMetadataCollationProvider[] providers; + private readonly IEndpointInspector endpointInspector; private readonly IOptions options; private readonly Activator activator; @@ -23,11 +24,13 @@ public ApiVersionDescriptionProviderFactory( Activator activator, ISunsetPolicyManager sunsetPolicyManager, IEnumerable providers, + IEndpointInspector endpointInspector, IOptions options ) { this.activator = activator; this.sunsetPolicyManager = sunsetPolicyManager; this.providers = providers.ToArray(); + this.endpointInspector = endpointInspector; this.options = options; } @@ -35,7 +38,7 @@ public IApiVersionDescriptionProvider Create( EndpointDataSource endpointDataSou { var collators = new List( capacity: providers.Length + 1 ) { - new EndpointApiVersionMetadataCollationProvider( endpointDataSource ), + new EndpointApiVersionMetadataCollationProvider( endpointDataSource, endpointInspector ), }; collators.AddRange( providers ); 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 1c1d2e63..29f0fba9 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 @@ -5,11 +5,13 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; using Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using static ServiceDescriptor; /// @@ -70,40 +72,36 @@ private static IApiVersionDescriptionProviderFactory ResolveApiVersionDescriptio { var sunsetPolicyManager = serviceProvider.GetRequiredService(); var providers = serviceProvider.GetServices(); + var inspector = serviceProvider.GetRequiredService(); var options = serviceProvider.GetRequiredService>(); - var mightUseCustomGroups = options.Value.FormatGroupName is not null; return new ApiVersionDescriptionProviderFactory( - mightUseCustomGroups ? NewGroupedProvider : NewDefaultProvider, + NewDefaultProvider, sunsetPolicyManager, providers, + inspector, options ); - static IApiVersionDescriptionProvider NewDefaultProvider( + static DefaultApiVersionDescriptionProvider NewDefaultProvider( IEnumerable providers, ISunsetPolicyManager sunsetPolicyManager, IOptions apiExplorerOptions ) => - new DefaultApiVersionDescriptionProvider( providers, sunsetPolicyManager, apiExplorerOptions ); - - static IApiVersionDescriptionProvider NewGroupedProvider( - IEnumerable providers, - ISunsetPolicyManager sunsetPolicyManager, - IOptions apiExplorerOptions ) => - new GroupedApiVersionDescriptionProvider( providers, sunsetPolicyManager, apiExplorerOptions ); + new( providers, sunsetPolicyManager, apiExplorerOptions ); } private static IApiVersionDescriptionProvider ResolveApiVersionDescriptionProvider( IServiceProvider serviceProvider ) { - var providers = serviceProvider.GetServices(); - var sunsetPolicyManager = serviceProvider.GetRequiredService(); - var options = serviceProvider.GetRequiredService>(); - var mightUseCustomGroups = options.Value.FormatGroupName is not null; + var factory = serviceProvider.GetRequiredService(); + var endpointDataSource = new EmptyEndpointDataSource(); + return factory.Create( endpointDataSource ); + } + + private sealed class EmptyEndpointDataSource : EndpointDataSource + { + public override IReadOnlyList Endpoints => Array.Empty(); - if ( mightUseCustomGroups ) - { - return new GroupedApiVersionDescriptionProvider( providers, sunsetPolicyManager, options ); - } + public override IChangeToken GetChangeToken() => new CancellationChangeToken( CancellationToken.None ); - return new DefaultApiVersionDescriptionProvider( providers, sunsetPolicyManager, options ); + public override IReadOnlyList GetGroupedEndpoints( RouteGroupContext context ) => Array.Empty(); } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/MvcEndpointInspector.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/MvcEndpointInspector.cs new file mode 100644 index 00000000..8729791c --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/MvcEndpointInspector.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +/// +/// Represents the inspector that understands +/// endpoints defined by MVC controllers. +/// +[CLSCompliant(false)] +public sealed class MvcEndpointInspector : IEndpointInspector +{ + /// + public bool IsControllerAction( Endpoint endpoint ) + { + ArgumentNullException.ThrowIfNull( endpoint ); + return endpoint.Metadata.Any( static attribute => attribute is ControllerAttribute ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs index 7796ca96..5f895312 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -67,6 +67,7 @@ private static void AddServices( IServiceCollection services ) services.TryAddEnumerable( Transient() ); services.TryAddEnumerable( Singleton() ); services.Replace( WithUrlHelperFactoryDecorator( services ) ); + services.TryReplace(); } private static object CreateInstance( this IServiceProvider services, ServiceDescriptor descriptor ) @@ -84,6 +85,23 @@ private static object CreateInstance( this IServiceProvider services, ServiceDes return ActivatorUtilities.GetServiceOrCreateInstance( services, descriptor.ImplementationType! ); } + private static void TryReplace( this IServiceCollection services ) + { + var serviceType = typeof( TService ); + var implementationType = typeof( TImplementation ); + + for ( var i = services.Count - 1; i >= 0; i-- ) + { + var service = services[i]; + + if ( service.ServiceType == serviceType && service.ImplementationType == implementationType ) + { + services[i] = Describe( serviceType, typeof( TReplacement ), service.Lifetime ); + break; + } + } + } + [SkipLocalsInit] private static DecoratedServiceDescriptor WithUrlHelperFactoryDecorator( IServiceCollection services ) { From 9811979f837007a0abd15c3c3eaac6ede5af821c Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Fri, 12 Jan 2024 15:17:24 -0800 Subject: [PATCH 2/2] Refactor and unify IApiVersionDescriptionProvider implementations. Explicit group names can only be determined at runtime. Related #1066 --- .../DefaultApiVersionDescriptionProvider.cs | 147 +++---------- .../GroupedApiVersionDescriptionProvider.cs | 200 ++---------------- .../ApiVersionDescriptionCollection.cs | 76 +++++++ .../Internal/ApiVersionDescriptionComparer.cs | 28 +++ .../Internal/DescriptionProvider.cs | 107 ++++++++++ .../Internal/GroupedApiVersion.cs | 5 + .../Internal/IGroupedApiVersionMetadata.cs | 20 ++ .../IGroupedApiVersionMetadataFactory.cs | 9 + 8 files changed, 291 insertions(+), 301 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionCollection.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionComparer.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/GroupedApiVersion.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadata.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadataFactory.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs index 63408cad..22151e11 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs @@ -2,9 +2,8 @@ namespace Asp.Versioning.ApiExplorer; +using Asp.Versioning.ApiExplorer.Internal; using Microsoft.Extensions.Options; -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. @@ -12,7 +11,7 @@ namespace Asp.Versioning.ApiExplorer; [CLSCompliant( false )] public class DefaultApiVersionDescriptionProvider : IApiVersionDescriptionProvider { - private readonly ApiVersionDescriptionCollection collection; + private readonly ApiVersionDescriptionCollection collection; private readonly IOptions options; /// @@ -28,7 +27,7 @@ public DefaultApiVersionDescriptionProvider( ISunsetPolicyManager sunsetPolicyManager, IOptions apiExplorerOptions ) { - collection = new( this, providers ?? throw new ArgumentNullException( nameof( providers ) ) ); + collection = new( Describe, providers ?? throw new ArgumentNullException( nameof( providers ) ) ); SunsetPolicyManager = sunsetPolicyManager; options = apiExplorerOptions; } @@ -58,133 +57,53 @@ protected virtual IReadOnlyList Describe( IReadOnlyList( capacity: metadata.Count ); - var supported = new HashSet(); - var deprecated = new HashSet(); - - BucketizeApiVersions( metadata, supported, deprecated ); - AppendDescriptions( descriptions, supported, deprecated: false ); - AppendDescriptions( descriptions, deprecated, deprecated: true ); - - return descriptions.OrderBy( d => d.ApiVersion ).ToArray(); - } - - private void BucketizeApiVersions( IReadOnlyList metadata, HashSet supported, HashSet deprecated ) - { - var declared = new HashSet(); - var advertisedSupported = new HashSet(); - var advertisedDeprecated = new HashSet(); - - for ( var i = 0; i < metadata.Count; i++ ) + // TODO: consider refactoring and removing GroupedApiVersionDescriptionProvider as both implementations are now + // effectively the same. this cast is safe as an internal implementation detail. if this method is + // overridden, then this code doesn't even run + // + // REF: https://github.com/dotnet/aspnet-api-versioning/issues/1066 + if ( metadata is GroupedApiVersionMetadata[] groupedMetadata ) { - var model = metadata[i].Map( Explicit | Implicit ); - var versions = model.DeclaredApiVersions; - - for ( var j = 0; j < versions.Count; j++ ) - { - declared.Add( versions[j] ); - } - - versions = model.SupportedApiVersions; - - for ( var j = 0; j < versions.Count; j++ ) - { - var version = versions[j]; - supported.Add( version ); - advertisedSupported.Add( version ); - } - - versions = model.DeprecatedApiVersions; - - for ( var j = 0; j < versions.Count; j++ ) - { - var version = versions[j]; - deprecated.Add( version ); - advertisedDeprecated.Add( version ); - } + return DescriptionProvider.Describe( groupedMetadata, SunsetPolicyManager, Options ); } - advertisedSupported.ExceptWith( declared ); - advertisedDeprecated.ExceptWith( declared ); - supported.ExceptWith( advertisedSupported ); - deprecated.ExceptWith( supported.Concat( advertisedDeprecated ) ); - - if ( supported.Count == 0 && deprecated.Count == 0 ) - { - supported.Add( Options.DefaultApiVersion ); - } + return Array.Empty(); } - private void AppendDescriptions( List descriptions, IEnumerable versions, bool deprecated ) + private sealed class GroupedApiVersionMetadata : + ApiVersionMetadata, + IEquatable, + IGroupedApiVersionMetadata, + IGroupedApiVersionMetadataFactory { - foreach ( var version in versions ) - { - var groupName = version.ToString( Options.GroupNameFormat, CurrentCulture ); - var sunsetPolicy = SunsetPolicyManager.TryGetPolicy( version, out var policy ) ? policy : default; - descriptions.Add( new( version, groupName, deprecated, sunsetPolicy ) ); - } - } + private GroupedApiVersionMetadata( string? groupName, ApiVersionMetadata metadata ) + : base( metadata ) => GroupName = groupName; - private sealed class ApiVersionDescriptionCollection( - DefaultApiVersionDescriptionProvider provider, - IEnumerable collators ) - { - private readonly object syncRoot = new(); - private readonly DefaultApiVersionDescriptionProvider provider = provider; - private readonly IApiVersionMetadataCollationProvider[] collators = collators.ToArray(); - private IReadOnlyList? items; - private int version; - - public IReadOnlyList Items - { - get - { - if ( items is not null && version == ComputeVersion() ) - { - return items; - } + public string? GroupName { get; } - lock ( syncRoot ) - { - var currentVersion = ComputeVersion(); + static GroupedApiVersionMetadata IGroupedApiVersionMetadataFactory.New( + string? groupName, + ApiVersionMetadata metadata ) => new( groupName, metadata ); - if ( items is not null && version == currentVersion ) - { - return items; - } + public bool Equals( GroupedApiVersionMetadata? other ) => + other is not null && other.GetHashCode() == GetHashCode(); - var context = new ApiVersionMetadataCollationContext(); + public override bool Equals( object? obj ) => + obj is not null && + GetType().Equals( obj.GetType() ) && + GetHashCode() == obj.GetHashCode(); - for ( var i = 0; i < collators.Length; i++ ) - { - collators[i].Execute( context ); - } - - items = provider.Describe( context.Results ); - version = currentVersion; - } - - return items; - } - } - - private int ComputeVersion() => - collators.Length switch - { - 0 => 0, - 1 => collators[0].Version, - _ => ComputeVersion( collators ), - }; - - private static int ComputeVersion( IApiVersionMetadataCollationProvider[] providers ) + public override int GetHashCode() { var hash = default( HashCode ); - for ( var i = 0; i < providers.Length; i++ ) + if ( !string.IsNullOrEmpty( GroupName ) ) { - hash.Add( providers[i].Version ); + hash.Add( GroupName, StringComparer.Ordinal ); } + hash.Add( base.GetHashCode() ); + return hash.ToHashCode(); } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs index ca31264b..294db52c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs @@ -2,10 +2,8 @@ namespace Asp.Versioning.ApiExplorer; +using Asp.Versioning.ApiExplorer.Internal; using Microsoft.Extensions.Options; -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. @@ -13,7 +11,7 @@ namespace Asp.Versioning.ApiExplorer; [CLSCompliant( false )] public class GroupedApiVersionDescriptionProvider : IApiVersionDescriptionProvider { - private readonly ApiVersionDescriptionCollection collection; + private readonly ApiVersionDescriptionCollection collection; private readonly IOptions options; /// @@ -29,7 +27,7 @@ public GroupedApiVersionDescriptionProvider( ISunsetPolicyManager sunsetPolicyManager, IOptions apiExplorerOptions ) { - collection = new( this, providers ?? throw new ArgumentNullException( nameof( providers ) ) ); + collection = new( Describe, providers ?? throw new ArgumentNullException( nameof( providers ) ) ); SunsetPolicyManager = sunsetPolicyManager; options = apiExplorerOptions; } @@ -59,191 +57,17 @@ public GroupedApiVersionDescriptionProvider( protected virtual IReadOnlyList Describe( IReadOnlyList metadata ) { ArgumentNullException.ThrowIfNull( 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( - GroupedApiVersionDescriptionProvider provider, - IEnumerable collators ) - { - private readonly object syncRoot = new(); - private readonly GroupedApiVersionDescriptionProvider provider = provider; - private readonly IApiVersionMetadataCollationProvider[] collators = collators.ToArray(); - private IReadOnlyList? items; - private int version; - - public IReadOnlyList Items - { - get - { - if ( items is not null && version == ComputeVersion() ) - { - return items; - } - - lock ( syncRoot ) - { - var currentVersion = ComputeVersion(); - - if ( items is not null && version == currentVersion ) - { - return items; - } - - var context = new ApiVersionMetadataCollationContext(); - - for ( var i = 0; i < collators.Length; i++ ) - { - collators[i].Execute( context ); - } - - var results = context.Results; - var metadata = new GroupedApiVersionMetadata[results.Count]; - - for ( var i = 0; i < metadata.Length; i++ ) - { - metadata[i] = new( context.Results.GroupName( i ), results[i] ); - } - - items = provider.Describe( metadata ); - version = currentVersion; - } - - return items; - } - } - - private int ComputeVersion() => - collators.Length switch - { - 0 => 0, - 1 => collators[0].Version, - _ => ComputeVersion( collators ), - }; - - private static int ComputeVersion( IApiVersionMetadataCollationProvider[] providers ) - { - var hash = default( HashCode ); - - for ( var i = 0; i < providers.Length; i++ ) - { - hash.Add( providers[i].Version ); - } - - return hash.ToHashCode(); - } - } - - 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; - } + return DescriptionProvider.Describe( metadata, SunsetPolicyManager, Options ); } /// /// Represents the API version metadata applied to an endpoint with an optional group name. /// - protected class GroupedApiVersionMetadata : ApiVersionMetadata, IEquatable + protected class GroupedApiVersionMetadata : + ApiVersionMetadata, + IEquatable, + IGroupedApiVersionMetadata, + IGroupedApiVersionMetadataFactory { /// /// Initializes a new instance of the class. @@ -259,6 +83,10 @@ public GroupedApiVersionMetadata( string? groupName, ApiVersionMetadata metadata /// The associated group name, if any. public string? GroupName { get; } + static GroupedApiVersionMetadata IGroupedApiVersionMetadataFactory.New( + string? groupName, + ApiVersionMetadata metadata ) => new( groupName, metadata ); + /// public bool Equals( GroupedApiVersionMetadata? other ) => other is not null && other.GetHashCode() == GetHashCode(); @@ -284,6 +112,4 @@ public override int 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/Internal/ApiVersionDescriptionCollection.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionCollection.cs new file mode 100644 index 00000000..f5847dd0 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionCollection.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer.Internal; + +internal sealed class ApiVersionDescriptionCollection( + Func, IReadOnlyList> describe, + IEnumerable collators ) + where T : IGroupedApiVersionMetadata, IGroupedApiVersionMetadataFactory +{ + private readonly object syncRoot = new(); + private readonly Func, IReadOnlyList> describe = describe; + private readonly IApiVersionMetadataCollationProvider[] collators = collators.ToArray(); + private IReadOnlyList? items; + private int version; + + public IReadOnlyList Items + { + get + { + if ( items is not null && version == ComputeVersion() ) + { + return items; + } + + lock ( syncRoot ) + { + var currentVersion = ComputeVersion(); + + if ( items is not null && version == currentVersion ) + { + return items; + } + + var context = new ApiVersionMetadataCollationContext(); + + for ( var i = 0; i < collators.Length; i++ ) + { + collators[i].Execute( context ); + } + + var results = context.Results; + var metadata = new T[results.Count]; + + for ( var i = 0; i < metadata.Length; i++ ) + { + metadata[i] = T.New( context.Results.GroupName( i ), results[i] ); + } + + items = describe( metadata ); + version = currentVersion; + } + + return items; + } + } + + private int ComputeVersion() => + collators.Length switch + { + 0 => 0, + 1 => collators[0].Version, + _ => ComputeVersion( collators ), + }; + + private static int ComputeVersion( IApiVersionMetadataCollationProvider[] providers ) + { + var hash = default( HashCode ); + + for ( var i = 0; i < providers.Length; i++ ) + { + hash.Add( providers[i].Version ); + } + + return hash.ToHashCode(); + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionComparer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionComparer.cs new file mode 100644 index 00000000..3fb73385 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/ApiVersionDescriptionComparer.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer.Internal; + +internal 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; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs new file mode 100644 index 00000000..ce3a0dbd --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer.Internal; + +using static Asp.Versioning.ApiVersionMapping; +using static System.Globalization.CultureInfo; + +internal static class DescriptionProvider +{ + internal static ApiVersionDescription[] Describe( + IReadOnlyList metadata, + ISunsetPolicyManager sunsetPolicyManager, + ApiExplorerOptions options ) + where T : IGroupedApiVersionMetadata, IEquatable + { + var descriptions = new SortedSet( new ApiVersionDescriptionComparer() ); + var supported = new HashSet(); + var deprecated = new HashSet(); + + BucketizeApiVersions( metadata, supported, deprecated, options ); + AppendDescriptions( descriptions, supported, sunsetPolicyManager, options, deprecated: false ); + AppendDescriptions( descriptions, deprecated, sunsetPolicyManager, options, deprecated: true ); + + return [.. descriptions]; + } + + private static void BucketizeApiVersions( + IReadOnlyList list, + HashSet supported, + HashSet deprecated, + ApiExplorerOptions options ) + where T : IGroupedApiVersionMetadata + { + 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 static void AppendDescriptions( + SortedSet descriptions, + HashSet versions, + ISunsetPolicyManager sunsetPolicyManager, + ApiExplorerOptions options, + bool deprecated ) + { + var format = options.GroupNameFormat; + var formatGroupName = options.FormatGroupName; + + foreach ( var (groupName, version) in versions ) + { + var formattedGroupName = groupName; + + if ( string.IsNullOrEmpty( formattedGroupName ) ) + { + formattedGroupName = version.ToString( format, CurrentCulture ); + } + else if ( formatGroupName is not null ) + { + formattedGroupName = formatGroupName( formattedGroupName, version.ToString( format, CurrentCulture ) ); + } + + var sunsetPolicy = sunsetPolicyManager.TryGetPolicy( version, out var policy ) ? policy : default; + descriptions.Add( new( version, formattedGroupName, deprecated, sunsetPolicy ) ); + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/GroupedApiVersion.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/GroupedApiVersion.cs new file mode 100644 index 00000000..8d276e60 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/GroupedApiVersion.cs @@ -0,0 +1,5 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer.Internal; + +internal record struct GroupedApiVersion( string? GroupName, ApiVersion ApiVersion ); \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadata.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadata.cs new file mode 100644 index 00000000..ec0c13e3 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadata.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer.Internal; + +internal interface IGroupedApiVersionMetadata +{ + string? GroupName { get; } + + string Name { get; } + + bool IsApiVersionNeutral { get; } + + ApiVersionModel Map( ApiVersionMapping mapping ); + + ApiVersionMapping MappingTo( ApiVersion? apiVersion ); + + bool IsMappedTo( ApiVersion? apiVersion ); + + void Deconstruct( out ApiVersionModel apiModel, out ApiVersionModel endpointModel ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadataFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadataFactory.cs new file mode 100644 index 00000000..ac9d885f --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/IGroupedApiVersionMetadataFactory.cs @@ -0,0 +1,9 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer.Internal; + +internal interface IGroupedApiVersionMetadataFactory + where T : IGroupedApiVersionMetadata +{ + static abstract T New( string? groupName, ApiVersionMetadata metadata ); +} \ No newline at end of file