From e0f3a7dbe17a65dc3423c8b63667cff9bd594d3d Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 18 Apr 2026 12:57:44 -0700 Subject: [PATCH 01/13] Exclude back-ported types from code coverage --- .../src/Common.Backport/ArgumentException.cs | 1 + .../Common.Backport/ArgumentNullException.cs | 1 + src/Common/src/Common.Backport/Array.cs | 1 + .../src/Common.Backport/BitOperations.cs | 1 + .../CallerArgumentExpressionAttribute.cs | 18 +++++++++--------- .../DateTimeOffsetExtensions.cs | 1 + src/Common/src/Common.Backport/HashCode.cs | 1 + .../src/Common.Backport/NullableAttributes.cs | 14 ++++++++++++++ .../src/Common.Backport/StringExtensions.cs | 1 + 9 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/Common/src/Common.Backport/ArgumentException.cs b/src/Common/src/Common.Backport/ArgumentException.cs index 5f52d82e..8e3b87ba 100644 --- a/src/Common/src/Common.Backport/ArgumentException.cs +++ b/src/Common/src/Common.Backport/ArgumentException.cs @@ -5,6 +5,7 @@ namespace Backport; using System.Runtime.CompilerServices; +[ExcludeFromCodeCoverage] internal static class ArgumentException { /// Throws an exception if is null or empty. diff --git a/src/Common/src/Common.Backport/ArgumentNullException.cs b/src/Common/src/Common.Backport/ArgumentNullException.cs index 4b74beaf..6ed2f9f4 100644 --- a/src/Common/src/Common.Backport/ArgumentNullException.cs +++ b/src/Common/src/Common.Backport/ArgumentNullException.cs @@ -5,6 +5,7 @@ namespace Backport; using System.Runtime.CompilerServices; +[ExcludeFromCodeCoverage] internal static class ArgumentNullException { /// Throws an if is null. diff --git a/src/Common/src/Common.Backport/Array.cs b/src/Common/src/Common.Backport/Array.cs index fc92d7be..5f8b5049 100644 --- a/src/Common/src/Common.Backport/Array.cs +++ b/src/Common/src/Common.Backport/Array.cs @@ -4,6 +4,7 @@ namespace Asp.Versioning; using System.Runtime.CompilerServices; +[ExcludeFromCodeCoverage] internal static class Array { [MethodImpl( MethodImplOptions.AggressiveInlining )] diff --git a/src/Common/src/Common.Backport/BitOperations.cs b/src/Common/src/Common.Backport/BitOperations.cs index 316bfb02..3ff4aaa2 100644 --- a/src/Common/src/Common.Backport/BitOperations.cs +++ b/src/Common/src/Common.Backport/BitOperations.cs @@ -9,6 +9,7 @@ namespace System.Numerics; using System.Runtime.CompilerServices; +[ExcludeFromCodeCoverage] internal static class BitOperations { /// diff --git a/src/Common/src/Common.Backport/CallerArgumentExpressionAttribute.cs b/src/Common/src/Common.Backport/CallerArgumentExpressionAttribute.cs index 329f7fe1..f8652c86 100644 --- a/src/Common/src/Common.Backport/CallerArgumentExpressionAttribute.cs +++ b/src/Common/src/Common.Backport/CallerArgumentExpressionAttribute.cs @@ -1,16 +1,16 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // REF: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/CallerArgumentExpressionAttribute.cs -namespace System.Runtime.CompilerServices +namespace System.Runtime.CompilerServices; + +[ExcludeFromCodeCoverage] +[AttributeUsage( AttributeTargets.Parameter, AllowMultiple = false, Inherited = false )] +internal sealed class CallerArgumentExpressionAttribute : Attribute { - [AttributeUsage( AttributeTargets.Parameter, AllowMultiple = false, Inherited = false )] - internal sealed class CallerArgumentExpressionAttribute : Attribute + public CallerArgumentExpressionAttribute( string parameterName ) { - public CallerArgumentExpressionAttribute( string parameterName ) - { - ParameterName = parameterName; - } - - public string ParameterName { get; } + ParameterName = parameterName; } + + public string ParameterName { get; } } \ No newline at end of file diff --git a/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs b/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs index 724e6525..1020ed04 100644 --- a/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs +++ b/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs @@ -2,6 +2,7 @@ namespace System; +[ExcludeFromCodeCoverage] internal static class DateTimeOffsetExtensions { private const long UnixEpochSeconds = 62_135_596_800L; diff --git a/src/Common/src/Common.Backport/HashCode.cs b/src/Common/src/Common.Backport/HashCode.cs index 1b37eb60..d0dbecf2 100644 --- a/src/Common/src/Common.Backport/HashCode.cs +++ b/src/Common/src/Common.Backport/HashCode.cs @@ -65,6 +65,7 @@ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT namespace System { + [ExcludeFromCodeCoverage] internal struct HashCode { private static readonly uint s_seed = GenerateGlobalSeed(); diff --git a/src/Common/src/Common.Backport/NullableAttributes.cs b/src/Common/src/Common.Backport/NullableAttributes.cs index 2cadf664..53c7522b 100644 --- a/src/Common/src/Common.Backport/NullableAttributes.cs +++ b/src/Common/src/Common.Backport/NullableAttributes.cs @@ -11,15 +11,19 @@ // The .NET Foundation licenses this file to you under the MIT license. namespace System.Diagnostics.CodeAnalysis; +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false )] internal sealed class AllowNullAttribute : Attribute { } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false )] internal sealed class DisallowNullAttribute : Attribute { } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Method, Inherited = false )] internal sealed class DoesNotReturnAttribute : Attribute { } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Parameter )] internal sealed class DoesNotReturnIfAttribute : Attribute { @@ -28,12 +32,19 @@ public DoesNotReturnIfAttribute( bool parameterValue ) { } public bool ParameterValue => default; } +#if NETSTANDARD1_0 || NETSTANDARD1_1 + +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Constructor | AttributeTargets.Event | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Struct, Inherited = false, AllowMultiple = false )] internal sealed class ExcludeFromCodeCoverageAttribute : Attribute { } +#endif + +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false )] internal sealed class MaybeNullAttribute : Attribute { } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Parameter )] internal sealed class MaybeNullWhenAttribute : Attribute { @@ -42,9 +53,11 @@ public MaybeNullWhenAttribute( bool returnValue ) { } public bool ReturnValue => default; } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false )] internal sealed class NotNullAttribute : Attribute { } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false )] internal sealed class NotNullIfNotNullAttribute : Attribute { @@ -53,6 +66,7 @@ public NotNullIfNotNullAttribute( string parameterName ) { } public string ParameterName => default!; } +[ExcludeFromCodeCoverage] [AttributeUsage( AttributeTargets.Parameter )] internal sealed class NotNullWhenAttribute : Attribute { diff --git a/src/Common/src/Common.Backport/StringExtensions.cs b/src/Common/src/Common.Backport/StringExtensions.cs index 5d50a4f9..0de63b39 100644 --- a/src/Common/src/Common.Backport/StringExtensions.cs +++ b/src/Common/src/Common.Backport/StringExtensions.cs @@ -4,6 +4,7 @@ namespace System; using System.Text; +[ExcludeFromCodeCoverage] internal static class StringExtensions { extension( string @string ) From 46dd516492ecde659be486cbb0423114aeb82d4e Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 18 Apr 2026 13:13:42 -0700 Subject: [PATCH 02/13] Implement IKeyedServiceProvider when injecting the ApiVersion via DI. Fixes #1178 --- .../Builder/EndpointBuilderFinalizer.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index 56f08f66..6ce91a17 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -282,7 +282,7 @@ private record struct ApiVersionBuckets( && AdvertisedDeprecated.Count == 0; } - private sealed class InjectApiVersion : IServiceProvider + private sealed class InjectApiVersion : IServiceProvider, IKeyedServiceProvider { private static readonly Type ApiVersionType = typeof( ApiVersion ); private readonly IServiceProvider provider; @@ -295,6 +295,12 @@ public InjectApiVersion( HttpContext context ) context.RequestServices = this; } + public object? GetKeyedService( Type serviceType, object? serviceKey ) => + provider.GetKeyedService( serviceType, serviceKey ); + + public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) => + provider.GetRequiredKeyedService( serviceType, serviceKey ); + public object? GetService( Type serviceType ) { if ( serviceType.IsAssignableFrom( ApiVersionType ) ) From 82792179c8dc876a0f6ca4090e8297863739f670 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 18 Apr 2026 14:56:00 -0700 Subject: [PATCH 03/13] Ensure keyed services are registered with a lowercase key to match the resolution side. Related To #1176 --- .../Configuration/ConfigureOpenApiOptions.cs | 4 +--- .../IApiVersioningBuilderExtensions.cs | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs index a8dc7380..5d88c80a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs @@ -2,14 +2,12 @@ #pragma warning disable CA1812 -namespace Asp.Versioning.OpenApi; +namespace Asp.Versioning.OpenApi.Configuration; using Asp.Versioning.ApiExplorer; -using Asp.Versioning.OpenApi.Configuration; using Asp.Versioning.OpenApi.Reflection; using Asp.Versioning.OpenApi.Transformers; using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; internal sealed class ConfigureOpenApiOptions( diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs index 13e1c648..8cbaff77 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -110,17 +110,23 @@ private static KeyedServiceContainer NewRequestServices( IServiceProvider servic { var provider = services.GetRequiredService(); var keyedServices = new KeyedServiceContainer( services ); - var names = new List(); + var openApi = typeof( IOpenApiDocumentProvider ); + var descriptions = provider.ApiVersionDescriptions; + var names = new List( descriptions.Count ); - foreach ( var description in provider.ApiVersionDescriptions ) + for ( var i = 0; i < descriptions.Count; i++ ) { - names.Add( description.GroupName ); - keyedServices.Add( Type.OpenApiSchemaService, description.GroupName, Class.OpenApiSchemaService.New ); - keyedServices.Add( Type.OpenApiDocumentService, description.GroupName, Class.OpenApiDocumentService.New ); - keyedServices.Add( - typeof( IOpenApiDocumentProvider ), - description.GroupName, - ( sp, k ) => sp.GetRequiredKeyedService( Type.OpenApiDocumentService, k ) ); + var description = descriptions[i]; + + // REF: https://github.com/dotnet/aspnetcore/blob/319e87fd950a99f3baae2aa79db3d4fb68783d85/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64 +#pragma warning disable CA1308 // Normalize strings to uppercase + var key = description.GroupName.ToLowerInvariant(); +#pragma warning restore CA1308 + + names.Add( key ); + keyedServices.Add( Type.OpenApiSchemaService, key, Class.OpenApiSchemaService.New ); + keyedServices.Add( Type.OpenApiDocumentService, key, Class.OpenApiDocumentService.New ); + keyedServices.Add( openApi, key, ( sp, k ) => sp.GetRequiredKeyedService( Type.OpenApiDocumentService, k ) ); } if ( names.Count > 0 ) From 5ff15d76fab8d3fc58fff081a864526b2ac2775f Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 18 Apr 2026 15:49:53 -0700 Subject: [PATCH 04/13] Fix nested keyed service resolution. Fixes #1177 --- .../IApiVersioningBuilderExtensions.cs | 16 ++--- .../KeyedServiceContainer.cs | 71 +++++-------------- 2 files changed, 26 insertions(+), 61 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs index 8cbaff77..3f639ff2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -109,8 +109,8 @@ private static object ResolveDocumentProvider( IServiceProvider provider ) => private static KeyedServiceContainer NewRequestServices( IServiceProvider services ) { var provider = services.GetRequiredService(); - var keyedServices = new KeyedServiceContainer( services ); - var openApi = typeof( IOpenApiDocumentProvider ); + var container = new KeyedServiceContainer( services ); + var type = typeof( IOpenApiDocumentProvider ); var descriptions = provider.ApiVersionDescriptions; var names = new List( descriptions.Count ); @@ -124,9 +124,9 @@ private static KeyedServiceContainer NewRequestServices( IServiceProvider servic #pragma warning restore CA1308 names.Add( key ); - keyedServices.Add( Type.OpenApiSchemaService, key, Class.OpenApiSchemaService.New ); - keyedServices.Add( Type.OpenApiDocumentService, key, Class.OpenApiDocumentService.New ); - keyedServices.Add( openApi, key, ( sp, k ) => sp.GetRequiredKeyedService( Type.OpenApiDocumentService, k ) ); + container.AddService( Type.OpenApiSchemaService, key, Class.OpenApiSchemaService.New ); + container.AddService( Type.OpenApiDocumentService, key, Class.OpenApiDocumentService.New ); + container.AddService( type, key, ( sp, k ) => sp.GetRequiredKeyedService( Type.OpenApiDocumentService, k ) ); } if ( names.Count > 0 ) @@ -138,10 +138,10 @@ private static KeyedServiceContainer NewRequestServices( IServiceProvider servic array.SetValue( Class.NamedService.New( names[i] ), i ); } - keyedServices.Add( Type.IDocumentProvider, Class.OpenApiDocumentProvider.New ); - keyedServices.Add( Type.IEnumerableOfNamedService, array ); + container.AddService( Type.IDocumentProvider, Class.OpenApiDocumentProvider.New ); + container.AddService( Type.IEnumerableOfNamedService, array ); } - return keyedServices; + return container; } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs index 71941c02..13f8f3d1 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs @@ -6,70 +6,35 @@ namespace Microsoft.Extensions.DependencyInjection; using System.ComponentModel.Design; -internal sealed class KeyedServiceContainer( IServiceProvider parent ) : - IServiceProvider, - IKeyedServiceProvider, - IServiceProviderIsService, - IServiceProviderIsKeyedService, - IDisposable +internal sealed class KeyedServiceContainer( IServiceProvider parent ) : ServiceContainer( parent ), IKeyedServiceProvider { - private readonly ServiceContainer services = new( parent ); + private readonly IServiceProvider parent = parent; private readonly Dictionary keyedServices = []; private bool disposed; - public object? GetKeyedService( Type serviceType, object? serviceKey ) + private object? GetKeyedService( Type serviceType, object? serviceKey ) { if ( serviceKey is not null && keyedServices.TryGetValue( serviceKey, out var container ) ) { - return container.GetService( serviceType ); + if ( container.GetService( serviceType ) is { } service ) + { + return service; + } } - return services.GetKeyedService( serviceType, serviceKey ); + return default; } - public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) - { - if ( serviceKey is not null && keyedServices.TryGetValue( serviceKey, out var container ) ) - { - return container.GetRequiredService( serviceType ); - } + object? IKeyedServiceProvider.GetKeyedService( Type serviceType, object? serviceKey ) => + GetKeyedService( serviceType, serviceKey ) ?? parent.GetKeyedService( serviceType, serviceKey ); - return services.GetRequiredKeyedService( serviceType, serviceKey ); - } + object IKeyedServiceProvider.GetRequiredKeyedService( Type serviceType, object? serviceKey ) => + GetKeyedService( serviceType, serviceKey ) ?? parent.GetRequiredKeyedService( serviceType, serviceKey ); - public object? GetService( Type serviceType ) => services.GetService( serviceType ); + public void AddService( Type serviceType, Func activator ) => + AddService( serviceType, ( sp, _ ) => activator( sp ) ); - public bool IsKeyedService( Type serviceType, object? serviceKey ) - { - if ( serviceKey is not null && keyedServices.ContainsKey( serviceKey ) ) - { - return true; - } - else if ( services.GetService() is { } service ) - { - return service.IsKeyedService( serviceType, serviceKey ); - } - - return false; - } - - public bool IsService( Type serviceType ) - { - if ( services.GetService() is { } service - && service.IsService( serviceType ) ) - { - return true; - } - - return services.GetService( serviceType ) is not null; - } - - public void Add( Type serviceType, object instance ) => services.AddService( serviceType, instance ); - - public void Add( Type serviceType, Func activator ) => - services.AddService( serviceType, ( _, _ ) => activator( this ) ); - - public void Add( Type serviceType, string serviceKey, Func activator ) + public void AddService( Type serviceType, string serviceKey, Func activator ) { if ( !keyedServices.TryGetValue( serviceKey, out var container ) ) { @@ -79,8 +44,10 @@ public void Add( Type serviceType, string serviceKey, Func activator( this, serviceKey ) ); } - public void Dispose() + protected override void Dispose( bool disposing ) { + base.Dispose( disposing ); + if ( disposed ) { return; @@ -92,7 +59,5 @@ public void Dispose() { container.Dispose(); } - - services.Dispose(); } } \ No newline at end of file From 398bdf69dc2d148d4003664033b355d00644b966 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 18 Apr 2026 16:02:23 -0700 Subject: [PATCH 05/13] Fix comparison between entry and calling assembly. Fixes #1175 --- .../DependencyInjection/IApiVersioningBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs index 3f639ff2..9504fc04 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -85,7 +85,7 @@ private static Assembly[] GetAssemblies( Assembly callingAssembly ) { var assemblies = new List( capacity: 2 ) { callingAssembly }; - if ( Assembly.GetEntryAssembly() is { } entryAssembly && assemblies[0] != callingAssembly ) + if ( Assembly.GetEntryAssembly() is { } entryAssembly && entryAssembly != callingAssembly ) { assemblies.Add( entryAssembly ); } From 258de1bb2f08407268ad8f804d0cbedcb9390db6 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 18 Apr 2026 16:17:41 -0700 Subject: [PATCH 06/13] Resolve the XmlCommentsTransformer via DI to allow a user-specified file path --- .../Configuration/ConfigureOpenApiOptions.cs | 3 +-- .../DependencyInjection/IApiVersioningBuilderExtensions.cs | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs index 5d88c80a..a32f8413 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs @@ -11,7 +11,7 @@ namespace Asp.Versioning.OpenApi.Configuration; using Microsoft.Extensions.Options; internal sealed class ConfigureOpenApiOptions( - XmlCommentsFile file, + XmlCommentsTransformer xmlComments, IApiVersionDescriptionProvider provider, VersionedOpenApiOptionsFactory factory ) : IPostConfigureOptions @@ -20,7 +20,6 @@ public void PostConfigure( string? name, OpenApiOptions options ) { var comparer = StringComparer.OrdinalIgnoreCase; var descriptions = provider.ApiVersionDescriptions; - var xmlComments = new XmlCommentsTransformer( file ); for ( var i = 0; i < descriptions.Count; i++ ) { diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs index 9504fc04..b0acea8e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -69,7 +69,8 @@ private static void AddOpenApiServices( IApiVersioningBuilder builder, Assembly[ services.AddSingleton(); services.TryAddEnumerable( Transient, ConfigureOpenApiOptions>() ); services.TryAdd( Singleton>( EM.GetRequiredService ) ); - builder.Services.AddSingleton( sp => new XmlCommentsFile( assemblies, sp.GetRequiredService() ) ); + services.AddTransient( sp => new XmlCommentsFile( assemblies, sp.GetRequiredService() ) ); + services.TryAddTransient( sp => new XmlCommentsTransformer( sp.GetRequiredService() ) ); if ( GetJsonConfiguration() is { } descriptor ) { From 48538e45b2e340a489294549f1f9e8a188e0ea98 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 19 Apr 2026 09:50:46 -0700 Subject: [PATCH 07/13] Remove used types load via Reflection --- .../WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs index 83e39e9a..14e85ea8 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs @@ -16,8 +16,6 @@ internal static class Type public static readonly System.Type IEnumerableOfNamedService = System.Type.GetType( "System.Collections.Generic.IEnumerable`1[[Microsoft.AspNetCore.OpenApi.NamedService`1[[Microsoft.AspNetCore.OpenApi.OpenApiDocumentService, Microsoft.AspNetCore.OpenApi]], Microsoft.AspNetCore.OpenApi]], System.Private.CoreLib", throwOnError: true )!; - public static readonly System.Type ListOfNamedService = System.Type.GetType( "System.Collections.Generic.List`1[[Microsoft.AspNetCore.OpenApi.NamedService`1[[Microsoft.AspNetCore.OpenApi.OpenApiDocumentService, Microsoft.AspNetCore.OpenApi]], Microsoft.AspNetCore.OpenApi]], System.Private.CoreLib", throwOnError: true )!; - [DynamicallyAccessedMembers( PublicConstructors )] public static readonly System.Type OpenApiDocumentProvider = System.Type.GetType( "Microsoft.Extensions.ApiDescriptions.OpenApiDocumentProvider, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; @@ -26,7 +24,4 @@ internal static class Type [DynamicallyAccessedMembers( PublicConstructors )] public static readonly System.Type OpenApiSchemaService = System.Type.GetType( "Microsoft.AspNetCore.OpenApi.OpenApiSchemaService, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; - - [DynamicallyAccessedMembers( PublicConstructors )] - public static readonly System.Type OpenApiSchemaJsonOptions = System.Type.GetType( "Microsoft.AspNetCore.OpenApi.OpenApiSchemaJsonOptions, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; } \ No newline at end of file From 34bd4910e0b042597f7846866da911b624cef1ef Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 19 Apr 2026 17:03:46 -0700 Subject: [PATCH 08/13] Add additional attribute constructors --- .../AdvertiseApiVersionsAttribute.cs | 19 ++++++- .../ApiVersionAttribute.cs | 13 +++++ .../MapToApiVersionAttribute.cs | 17 +++++- .../AdvertiseApiVersionsAttributeTest.cs | 57 +++++++++++++++++++ .../ApiVersionAttributeTest.cs | 30 ++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs index ca183af6..c9406315 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/AdvertiseApiVersionsAttribute.cs @@ -8,6 +8,9 @@ namespace Asp.Versioning; using static System.AttributeTargets; +#if NETSTANDARD +using DateOnly = System.DateTime; +#endif /// /// Represents the metadata that describes the advertised API versions. @@ -61,8 +64,20 @@ public AdvertiseApiVersionsAttribute( double version, params double[] otherVersi /// /// Initializes a new instance of the class. /// - /// The API version string. - public AdvertiseApiVersionsAttribute( string version ) : base( version ) { } + /// A numeric API version. + /// The status associated with the API version, if any. + public AdvertiseApiVersionsAttribute( double version, string? status = default ) + : base( new ApiVersion( version, status ) ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The version year. + /// The version month. + /// The version day. + /// The status associated with the API version, if any. + public AdvertiseApiVersionsAttribute( int year, int month, int day, string? status = default ) + : base( new ApiVersion( new DateOnly( year, month, day ), status ) ) { } /// /// Initializes a new instance of the class. diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs index 90657500..a532de97 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ApiVersionAttribute.cs @@ -8,6 +8,9 @@ namespace Asp.Versioning; using static System.AttributeTargets; +#if NETSTANDARD +using DateOnly = System.DateTime; +#endif /// /// Represents the metadata that describes the versions associated with an API. @@ -38,6 +41,16 @@ protected ApiVersionAttribute( IApiVersionParser parser, string version ) : base /// The status associated with the API version, if any. public ApiVersionAttribute( double version, string? status = default ) : base( new ApiVersion( version, status ) ) { } + /// + /// Initializes a new instance of the class. + /// + /// The version year. + /// The version month. + /// The version day. + /// The status associated with the API version, if any. + public ApiVersionAttribute( int year, int month, int day, string? status = default ) + : base( new ApiVersion( new DateOnly( year, month, day ), status ) ) { } + /// /// Initializes a new instance of the class. /// diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs index e90f5c58..9589248c 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/MapToApiVersionAttribute.cs @@ -8,6 +8,9 @@ namespace Asp.Versioning; using static System.AttributeTargets; +#if NETSTANDARD +using DateOnly = System.DateTime; +#endif /// /// Represents the metadata that describes the version-specific implementation of an API. @@ -32,7 +35,19 @@ protected MapToApiVersionAttribute( IApiVersionParser parser, string version ) : /// Initializes a new instance of the class. /// /// A numeric API version. - public MapToApiVersionAttribute( double version ) : base( version ) { } + /// The status associated with the API version, if any. + public MapToApiVersionAttribute( double version, string? status = default ) + : base( new ApiVersion( version, status ) ) { } + + /// + /// Initializes a new instance of the class. + /// + /// The version year. + /// The version month. + /// The version day. + /// The status associated with the API version, if any. + public MapToApiVersionAttribute( int year, int month, int day, string? status = default ) + : base( new ApiVersion( new DateOnly( year, month, day ), status ) ) { } /// /// Initializes a new instance of the class. diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/AdvertiseApiVersionsAttributeTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/AdvertiseApiVersionsAttributeTest.cs index 00dfe648..b5f04c9e 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/AdvertiseApiVersionsAttributeTest.cs +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/AdvertiseApiVersionsAttributeTest.cs @@ -3,6 +3,9 @@ namespace Asp.Versioning; using static Asp.Versioning.ApiVersionProviderOptions; +#if NETFRAMEWORK +using DateOnly = System.DateTime; +#endif public class AdvertiseApiVersionsAttributeTest { @@ -20,4 +23,58 @@ public void new_advertise_api_versions_attribute_should_have_expected_options( b // assert options.Should().Be( expected ); } + + [Fact] + public void advertise_api_versions_base_attribute_should_initialize_from_array_of_double() + { + // arrange + var version = 1.0; + var otherVersions = new[] { 2.0, 3.0 }; + + // act + var attribute = new AdvertiseApiVersionsAttribute( version, otherVersions ); + + // asserts + attribute.Versions.Should().BeEquivalentTo( new ApiVersion[] { new( 1.0 ), new( 2.0 ), new( 3.0 ) } ); + } + + [Fact] + public void advertise_api_versions_base_attribute_should_initialize_from_array_of_string() + { + // arrange + var version = "1.0"; + var otherVersions = new[] { "2.0", "3.0" }; + + // act + var attribute = new AdvertiseApiVersionsAttribute( version, otherVersions ); + + // assert + attribute.Versions.Should().BeEquivalentTo( new ApiVersion[] { new( 1.0 ), new( 2.0 ), new( 3.0 ) } ); + } + + [Fact] + public void advertise_api_version_attribute_should_initialize_from_date() + { + // arrange + var expected = new ApiVersion( new DateOnly( 2016, 1, 1 ) ); + + // act + var attribute = new AdvertiseApiVersionsAttribute( 2016, 1, 1 ); + + // assert + attribute.Versions[0].Should().Be( expected ); + } + + [Fact] + public void advertise_api_version_attribute_should_initialize_from_date_with_status() + { + // arrange + var expected = new ApiVersion( new DateOnly( 2016, 1, 1 ), "alpha" ); + + // act + var attribute = new AdvertiseApiVersionsAttribute( 2016, 1, 1, "alpha" ); + + // assert + attribute.Versions[0].Should().Be( expected ); + } } \ No newline at end of file diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionAttributeTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionAttributeTest.cs index dfbcd15e..094c3b22 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionAttributeTest.cs +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ApiVersionAttributeTest.cs @@ -2,6 +2,10 @@ namespace Asp.Versioning; +#if NETFRAMEWORK +using DateOnly = System.DateTime; +#endif + public class ApiVersionAttributeTest { [Theory] @@ -50,4 +54,30 @@ public void api_version_attribute_should_initialize_from_double_with_status() // assert attribute.Versions[0].Should().Be( expected ); } + + [Fact] + public void api_version_attribute_should_initialize_from_date() + { + // arrange + var expected = new ApiVersion( new DateOnly( 2016, 1, 1 ) ); + + // act + var attribute = new ApiVersionAttribute( 2016, 1, 1 ); + + // assert + attribute.Versions[0].Should().Be( expected ); + } + + [Fact] + public void api_version_attribute_should_initialize_from_date_with_status() + { + // arrange + var expected = new ApiVersion( new DateOnly( 2016, 1, 1 ), "alpha" ); + + // act + var attribute = new ApiVersionAttribute( 2016, 1, 1, "alpha" ); + + // assert + attribute.Versions[0].Should().Be( expected ); + } } \ No newline at end of file From 1975be0cb9ebd49f88da5f66de43569f7675b4d8 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 19 Apr 2026 21:38:40 -0700 Subject: [PATCH 09/13] Public members should be virtual --- .../Transformers/ApiExplorerTransformer.cs | 12 +++++----- .../Transformers/XmlComments.cs | 22 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs index 905fe4c9..f53ab61d 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs @@ -43,7 +43,7 @@ public class ApiExplorerTransformer : protected string ExtensionName { get; set; } = "x-api-versioning"; /// - public Task TransformAsync( + public virtual Task TransformAsync( OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken ) @@ -51,17 +51,17 @@ public Task TransformAsync( ArgumentNullException.ThrowIfNull( schema ); ArgumentNullException.ThrowIfNull( context ); - if ( schema.Default is null - && context.ParameterDescription?.DefaultValue is string value ) + if ( context.ParameterDescription?.DefaultValue is string value ) { - schema.Default = JsonNode.Parse( $"\"{value}\"" ); + schema.Enum ??= new List(1); + schema.Enum.Add( JsonNode.Parse( $"\"{value}\"" )! ); } return Task.CompletedTask; } /// - public Task TransformAsync( + public virtual Task TransformAsync( OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken ) @@ -80,7 +80,7 @@ public Task TransformAsync( } /// - public Task TransformAsync( + public virtual Task TransformAsync( OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs index 59a5c710..27da88c4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs @@ -43,7 +43,7 @@ public class XmlComments /// /// The member to get the summary from. /// The corresponding <summary> or an empty string. - public string GetSummary( MemberInfo member ) + public virtual string GetSummary( MemberInfo member ) => GetMember( member )?.Element( "summary" )?.Value.Trim() ?? string.Empty; /// @@ -51,7 +51,7 @@ public string GetSummary( MemberInfo member ) /// /// The member to get the description from. /// The corresponding <description> or an empty string. - public string GetDescription( MemberInfo member ) + public virtual string GetDescription( MemberInfo member ) => GetMember( member )?.Element( "description" )?.Value.Trim() ?? string.Empty; /// @@ -59,7 +59,7 @@ public string GetDescription( MemberInfo member ) /// /// The member to get the remarks from. /// The corresponding <remarks> or an empty string. - public string GetRemarks( MemberInfo member ) + public virtual string GetRemarks( MemberInfo member ) => GetMember( member )?.Element( "remarks" )?.Value.Trim() ?? string.Empty; /// @@ -67,7 +67,7 @@ public string GetRemarks( MemberInfo member ) /// /// The member to get the returns from. /// The corresponding <returns> or an empty string. - public string GetReturns( MemberInfo member ) + public virtual string GetReturns( MemberInfo member ) => GetMember( member )?.Element( "returns" )?.Value.Trim() ?? string.Empty; /// @@ -75,7 +75,7 @@ public string GetReturns( MemberInfo member ) /// /// The member to get the example from. /// The corresponding <example> or an empty string. - public string GetExample( MemberInfo member ) + public virtual string GetExample( MemberInfo member ) => GetMember( member )?.Element( "example" )?.Value.Trim() ?? string.Empty; /// @@ -84,7 +84,7 @@ public string GetExample( MemberInfo member ) /// The member to get the parameter from. /// The name of the parameter. /// The corresponding description or an empty string. - public string GetParameterDescription( MemberInfo member, string name ) + public virtual string GetParameterDescription( MemberInfo member, string name ) { if ( GetMember( member ) is { } element ) { @@ -103,7 +103,7 @@ public string GetParameterDescription( MemberInfo member, string name ) /// The member to get the parameter from. /// The name of the parameter. /// The corresponding <example> or an empty string. - public string GetParameterExample( MemberInfo member, string name ) + public virtual string GetParameterExample( MemberInfo member, string name ) { if ( GetMember( member ) is { } element ) { @@ -124,7 +124,7 @@ public string GetParameterExample( MemberInfo member, string name ) /// The name of the parameter. /// true if the deprecated attribute is present with a value of "true"; /// otherwise false. - public bool IsParameterDeprecated( MemberInfo member, string name ) + public virtual bool IsParameterDeprecated( MemberInfo member, string name ) { if ( GetMember( member ) is { } element ) { @@ -151,7 +151,7 @@ public bool IsParameterDeprecated( MemberInfo member, string name ) /// Swashbuckle; for example, <response code="200">The operation was successful</response>. See the /// tutorial /// for more information. - public string GetResponseDescription( MemberInfo member, int statusCode ) + public virtual string GetResponseDescription( MemberInfo member, int statusCode ) => GetResponseDescription( member, statusCode.ToString( CultureInfo.InvariantCulture ) ); /// @@ -164,7 +164,7 @@ public string GetResponseDescription( MemberInfo member, int statusCode ) /// Swashbuckle; for example, <response code="200">The operation was successful</response>. See the /// tutorial /// for more information. - public string GetResponseDescription( MemberInfo member, string statusCode ) + public virtual string GetResponseDescription( MemberInfo member, string statusCode ) { if ( GetMember( member ) is { } element ) { @@ -182,7 +182,7 @@ public string GetResponseDescription( MemberInfo member, string statusCode ) /// /// The member to get the information for. /// The representing the matching member element or null. - protected XElement? GetMember( MemberInfo member ) => + protected virtual XElement? GetMember( MemberInfo member ) => GetMemberById( XmlCommentsProvider.GetDocumentationMemberId( member ) ); private static XElement? FindMember( XDocument xml, string key ) => From 8d6dac3cbeb003a2626acec780ba0ae66fb401d9 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 19 Apr 2026 21:40:52 -0700 Subject: [PATCH 10/13] Move file up one level --- .../{Transformers => }/StringBuilderExtensions.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/{Transformers => }/StringBuilderExtensions.cs (100%) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/StringBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs similarity index 100% rename from src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/StringBuilderExtensions.cs rename to src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs From 21e00ad9e1b3676a10f92da04a4a0f25b6664e7a Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 19 Apr 2026 21:41:06 -0700 Subject: [PATCH 11/13] Update namespace --- .../src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs index b5bc8209..3fe87d8e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/StringBuilderExtensions.cs @@ -1,6 +1,6 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. -namespace Asp.Versioning.OpenApi.Transformers; +namespace Asp.Versioning.OpenApi; using System.Text; From aa8bb4c31c663c04dedf7856280a5d473c07861a Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 20 Apr 2026 08:56:33 -0700 Subject: [PATCH 12/13] Update asserted OpenAPI documents to use "enum" instead of "default" --- .../test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json | 2 +- .../test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json | 4 ++-- .../WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json index 8fb590a9..7702c254 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json @@ -38,7 +38,7 @@ "required": true, "schema": { "type": "string", - "default": "1.0" + "enum": [ "1.0" ] } } ], diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json index d352c3d1..944e40bf 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json @@ -38,7 +38,7 @@ "required": true, "schema": { "type": "string", - "default": "1.0" + "enum": [ "1.0" ] } } ], @@ -93,7 +93,7 @@ "required": true, "schema": { "type": "string", - "default": "1.0" + "enum": [ "1.0" ] } } ], diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json index 037036f4..fc463d7a 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json @@ -40,7 +40,7 @@ "required": true, "schema": { "type": "string", - "default": "1.0" + "enum": ["1.0"] } } ], From f1f704270d28532cc30aec64fda6cb8074cfab3a Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Tue, 21 Apr 2026 07:53:22 -0700 Subject: [PATCH 13/13] Ensure service scopes are preserved --- .../Builder/EndpointBuilderFinalizer.cs | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index 6ce91a17..df8c73f3 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -76,7 +76,11 @@ private static void Finialize( EndpointBuilder endpointBuilder, ApiVersionSet? v endpointBuilder.RequestDelegate = context => { - context.RequestServices = new InjectApiVersion( context ); + if ( context.RequestServices is not InjectApiVersion ) + { + context.RequestServices = new InjectApiVersion( context ); + } + return requestDelegate( context ); }; } @@ -282,11 +286,12 @@ private record struct ApiVersionBuckets( && AdvertisedDeprecated.Count == 0; } - private sealed class InjectApiVersion : IServiceProvider, IKeyedServiceProvider + private sealed class InjectApiVersion : IKeyedServiceProvider, IServiceScopeFactory { - private static readonly Type ApiVersionType = typeof( ApiVersion ); private readonly IServiceProvider provider; private readonly HttpContext context; + internal static readonly Type ApiVersionType = typeof( ApiVersion ); + internal static readonly Type ServiceScopeFactoryType = typeof( IServiceScopeFactory ); public InjectApiVersion( HttpContext context ) { @@ -295,6 +300,10 @@ public InjectApiVersion( HttpContext context ) context.RequestServices = this; } +#pragma warning disable CA2000 // Dispose objects before losing scope + public IServiceScope CreateScope() => new ApiVersionScope( context, provider.CreateScope() ); +#pragma warning restore CA2000 + public object? GetKeyedService( Type serviceType, object? serviceKey ) => provider.GetKeyedService( serviceType, serviceKey ); @@ -307,8 +316,55 @@ public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) => { return context.RequestedApiVersion; } + else if ( serviceType.Equals( ServiceScopeFactoryType ) ) + { + return this; + } return provider.GetService( serviceType ); } } + + private sealed class ApiVersionScope( HttpContext context, IServiceScope scope ) + : IKeyedServiceProvider, IServiceScopeFactory, IServiceScope + { + private bool disposed; + + public IServiceProvider ServiceProvider => this; + +#pragma warning disable CA2000 // Dispose objects before losing scope + public IServiceScope CreateScope() => new ApiVersionScope( context, scope.ServiceProvider.CreateScope() ); +#pragma warning restore CA2000 + + public object? GetKeyedService( Type serviceType, object? serviceKey ) => + scope.ServiceProvider.GetKeyedService( serviceType, serviceKey ); + + public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) => + scope.ServiceProvider.GetRequiredKeyedService( serviceType, serviceKey ); + + public object? GetService( Type serviceType ) + { + if ( serviceType.IsAssignableFrom( InjectApiVersion.ApiVersionType ) ) + { + return context.RequestedApiVersion; + } + else if ( serviceType.Equals( InjectApiVersion.ServiceScopeFactoryType ) ) + { + return this; + } + + return scope.ServiceProvider.GetService( serviceType ); + } + + public void Dispose() + { + if ( disposed ) + { + return; + } + + disposed = true; + scope.Dispose(); + } + } } \ No newline at end of file