From bde8d62bb6b0097552e903f8e8c21aa1207ce9a8 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Mon, 11 May 2026 22:12:52 +0200 Subject: [PATCH 1/4] Update OpenApi implementation to avoid reflection --- .../MinimalOpenApiExample.csproj | 2 + .../IEndpointConventionBuilderExtensions.cs | 7 +- .../Configuration/ConfigureOpenApiOptions.cs | 4 +- .../AggregateKeyedServiceProvider.cs | 60 ++++++++++ .../IApiVersioningBuilderExtensions.cs | 56 ++------- .../KeyedServiceContainer.cs | 63 ---------- .../Reflection/Class.cs | 111 ------------------ .../Reflection/Property.cs | 30 ----- .../Asp.Versioning.OpenApi/Reflection/Type.cs | 27 ----- 9 files changed, 76 insertions(+), 284 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Class.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Property.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj b/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj index 0c742552..bd6caa8b 100644 --- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj +++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj @@ -4,6 +4,8 @@ net10.0 Example API true + false + false diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs index aa2559fe..730f21c9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs @@ -6,7 +6,6 @@ namespace Microsoft.AspNetCore.Builder; using Asp.Versioning; using Asp.Versioning.ApiExplorer; -using Asp.Versioning.OpenApi.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -44,12 +43,12 @@ private static void ApplyApiVersioning( EndpointBuilder builder ) private static Task InterceptRequestServices( HttpContext context, RequestDelegate action ) { - if ( context.RequestServices is not KeyedServiceContainer requestServices ) + if ( context.RequestServices is not AggregateKeyedServiceProvider serviceProvider ) { - requestServices = context.RequestServices.GetRequiredService(); + serviceProvider = context.RequestServices.GetRequiredService(); } - context.RequestServices = requestServices; + context.RequestServices = serviceProvider; return action( context ); } } \ No newline at end of file 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 a32f8413..aef29c1d 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs @@ -5,10 +5,11 @@ namespace Asp.Versioning.OpenApi.Configuration; using Asp.Versioning.ApiExplorer; -using Asp.Versioning.OpenApi.Reflection; using Asp.Versioning.OpenApi.Transformers; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; +using System.Net; +using System.Runtime.CompilerServices; internal sealed class ConfigureOpenApiOptions( XmlCommentsTransformer xmlComments, @@ -48,7 +49,6 @@ private static void Configure( VersionedOpenApiOptions versionedOptions, XmlComm var options = versionedOptions.Document; var apiExplorer = new ApiExplorerTransformer( versionedOptions ); - options.SetDocumentName( versionedOptions.Description.GroupName ); options.AddDocumentTransformer( apiExplorer ); options.AddSchemaTransformer( apiExplorer ); options.AddOperationTransformer( apiExplorer ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs new file mode 100644 index 00000000..2fd7c997 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs @@ -0,0 +1,60 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable IDE0130 + +namespace Microsoft.Extensions.DependencyInjection; + +internal sealed class AggregateKeyedServiceProvider( IServiceProvider parent ) : IKeyedServiceProvider +{ + private readonly IServiceProvider parent = parent; + private readonly List providers = []; + + public object? GetKeyedService( Type serviceType, object? serviceKey ) + { + if ( providers.Count == 0 ) + { + return parent.GetKeyedService( serviceType, serviceKey ); + } + + foreach ( var provider in providers ) + { + if ( provider.GetKeyedService( serviceType, serviceKey ) is { } service ) + { + return service; + } + } + + return null; + } + + public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) + { + if ( providers.Count == 0 ) + { + return parent.GetRequiredKeyedService( serviceType, serviceKey ); + } + + for ( int i = 0; i < providers.Count - 1; i++ ) + { + if ( providers[i].GetKeyedService( serviceType, serviceKey ) is { } service ) + { + return service; + } + } + + return providers[providers.Count - 1].GetRequiredKeyedService( serviceType, serviceKey ); + } + + public object? GetService( Type serviceType ) + => parent.GetService( serviceType ); + + public void Add( IServiceCollection serviceCollection, IServiceCollection parentServiceCollection ) + { + foreach ( var descriptor in parentServiceCollection ) + { + serviceCollection.Add( descriptor ); + } + + providers.Add( serviceCollection.BuildServiceProvider() ); + } +} \ No newline at end of file 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 b0acea8e..f861ed56 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -8,13 +8,14 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning.ApiExplorer; using Asp.Versioning.OpenApi; using Asp.Versioning.OpenApi.Configuration; -using Asp.Versioning.OpenApi.Reflection; using Asp.Versioning.OpenApi.Transformers; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using System.ComponentModel.Design; +using System.Diagnostics; using System.Reflection; using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; using EM = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions; @@ -64,18 +65,13 @@ private static void AddOpenApiServices( IApiVersioningBuilder builder, Assembly[ var services = builder.Services; - services.AddTransient( NewRequestServices ); - services.Add( Singleton( Type.IDocumentProvider, ResolveDocumentProvider ) ); + services.AddTransient( serviceProvider => NewRequestServices( serviceProvider, services ) ); + services.AddSingleton(); services.TryAddEnumerable( Transient, ConfigureOpenApiOptions>() ); services.TryAdd( Singleton>( EM.GetRequiredService ) ); services.AddTransient( sp => new XmlCommentsFile( assemblies, sp.GetRequiredService() ) ); services.TryAddTransient( sp => new XmlCommentsTransformer( sp.GetRequiredService() ) ); - - if ( GetJsonConfiguration() is { } descriptor ) - { - services.TryAddEnumerable( descriptor ); - } } // NOTE: The calling assembly must be captured at the call site that invokes AddOpenApi. In 99% of the cases that @@ -94,53 +90,19 @@ private static Assembly[] GetAssemblies( Assembly callingAssembly ) return [.. assemblies]; } - // HACK: the json configuration is internal; this approach negates the use of reflection - // REF: https://github.com/dotnet/aspnetcore/blob/08a9fc2c3864d99759ab3d71cfda868d852bfc4b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L121 - private static ServiceDescriptor? GetJsonConfiguration() - { - var services = new ServiceCollection(); - services.AddOpenApi( "*" ); - return services.SingleOrDefault( sd => sd.ServiceType == typeof( IConfigureOptions ) ); - } - - private static object ResolveDocumentProvider( IServiceProvider provider ) => - provider.GetRequiredService().GetRequiredService( Type.IDocumentProvider ); - [UnconditionalSuppressMessage( "ILLink", "IL3050" )] - private static KeyedServiceContainer NewRequestServices( IServiceProvider services ) + private static AggregateKeyedServiceProvider NewRequestServices( IServiceProvider services, IServiceCollection parentServiceCollection ) { var provider = services.GetRequiredService(); - var container = new KeyedServiceContainer( services ); - var type = typeof( IOpenApiDocumentProvider ); + var container = new AggregateKeyedServiceProvider( services ); var descriptions = provider.ApiVersionDescriptions; - var names = new List( descriptions.Count ); for ( var i = 0; i < descriptions.Count; i++ ) { 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 ); - 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 ) - { - var array = Array.CreateInstance( Type.NamedService, names.Count ); - - for ( var i = 0; i < names.Count; i++ ) - { - array.SetValue( Class.NamedService.New( names[i] ), i ); - } - - container.AddService( Type.IDocumentProvider, Class.OpenApiDocumentProvider.New ); - container.AddService( Type.IEnumerableOfNamedService, array ); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddOpenApi( description.GroupName ); + container.Add( serviceCollection, parentServiceCollection ); } return container; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs deleted file mode 100644 index 13f8f3d1..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/KeyedServiceContainer.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -#pragma warning disable IDE0130 - -namespace Microsoft.Extensions.DependencyInjection; - -using System.ComponentModel.Design; - -internal sealed class KeyedServiceContainer( IServiceProvider parent ) : ServiceContainer( parent ), IKeyedServiceProvider -{ - private readonly IServiceProvider parent = parent; - private readonly Dictionary keyedServices = []; - private bool disposed; - - private object? GetKeyedService( Type serviceType, object? serviceKey ) - { - if ( serviceKey is not null && keyedServices.TryGetValue( serviceKey, out var container ) ) - { - if ( container.GetService( serviceType ) is { } service ) - { - return service; - } - } - - return default; - } - - object? IKeyedServiceProvider.GetKeyedService( Type serviceType, object? serviceKey ) => - GetKeyedService( serviceType, serviceKey ) ?? parent.GetKeyedService( serviceType, serviceKey ); - - object IKeyedServiceProvider.GetRequiredKeyedService( Type serviceType, object? serviceKey ) => - GetKeyedService( serviceType, serviceKey ) ?? parent.GetRequiredKeyedService( serviceType, serviceKey ); - - public void AddService( Type serviceType, Func activator ) => - AddService( serviceType, ( sp, _ ) => activator( sp ) ); - - public void AddService( Type serviceType, string serviceKey, Func activator ) - { - if ( !keyedServices.TryGetValue( serviceKey, out var container ) ) - { - keyedServices.Add( serviceKey, container = new() ); - } - - container.AddService( serviceType, ( _, _ ) => activator( this, serviceKey ) ); - } - - protected override void Dispose( bool disposing ) - { - base.Dispose( disposing ); - - if ( disposed ) - { - return; - } - - disposed = true; - - foreach ( var container in keyedServices.Values ) - { - container.Dispose(); - } - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Class.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Class.cs deleted file mode 100644 index 4d4ec845..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Class.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.OpenApi.Reflection; - -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using System.Linq.Expressions; -using static System.Linq.Expressions.Expression; - -// HACK: all of these types are internal in Microsoft.AspNetCore.OpenApi -// REF: https://github.com/dotnet/aspnetcore/tree/main/src/OpenApi/src -internal static class Class -{ - public static class OpenApiDocumentService - { - private static readonly Func factory = NewFactory(); - - public static object New( IServiceProvider serviceProvider, string documentName ) => factory( serviceProvider, documentName ); - - private static Func NewFactory() - { - var constructor = Type.OpenApiDocumentService.GetConstructors().Single(); - var serviceProvider = Parameter( typeof( IServiceProvider ), "serviceProvider" ); - var documentName = Parameter( typeof( string ), "documentName" ); - var getRequiredService = typeof( ServiceProviderServiceExtensions ).GetMethod( - nameof( ServiceProviderServiceExtensions.GetRequiredService ), - [typeof( IServiceProvider ), typeof( System.Type )] )!; - var apiDescriptionGroupCollectionProvider = typeof( IApiDescriptionGroupCollectionProvider ); - var hostEnvironment = typeof( IHostEnvironment ); - var optionsMonitor = typeof( IOptionsMonitor ); - var server = typeof( IServer ); - var body = Expression.New( - constructor, - documentName, - Convert( Call( getRequiredService, serviceProvider, Constant( apiDescriptionGroupCollectionProvider ) ), apiDescriptionGroupCollectionProvider ), - Convert( Call( getRequiredService, serviceProvider, Constant( hostEnvironment ) ), hostEnvironment ), - Convert( Call( getRequiredService, serviceProvider, Constant( optionsMonitor ) ), optionsMonitor ), - serviceProvider, - Convert( Call( getRequiredService, serviceProvider, Constant( server ) ), server ) ); - var lambda = Lambda>( body, serviceProvider, documentName ); - - return lambda.Compile(); - } - } - - public static class OpenApiSchemaService - { - private static readonly Func factory = NewFactory(); - - public static object New( IServiceProvider serviceProvider, string documentName ) => factory( serviceProvider, documentName ); - - private static Func NewFactory() - { - var constructor = Type.OpenApiSchemaService.GetConstructors().Single(); - var serviceProvider = Parameter( typeof( IServiceProvider ), "serviceProvider" ); - var documentName = Parameter( typeof( string ), "documentName" ); - var getRequiredService = typeof( ServiceProviderServiceExtensions ).GetMethod( - nameof( ServiceProviderServiceExtensions.GetRequiredService ), - [typeof( IServiceProvider ), typeof( System.Type )] )!; - var jsonOptions = typeof( IOptions ); - var optionsMonitor = typeof( IOptionsMonitor ); - var body = Expression.New( - constructor, - documentName, - Convert( Call( getRequiredService, serviceProvider, Constant( jsonOptions ) ), jsonOptions ), - Convert( Call( getRequiredService, serviceProvider, Constant( optionsMonitor ) ), optionsMonitor ) ); - var lambda = Lambda>( body, serviceProvider, documentName ); - - return lambda.Compile(); - } - } - - public static class OpenApiDocumentProvider - { - private static readonly Func factory = NewFactory(); - - public static object New( IServiceProvider serviceProvider ) => factory( serviceProvider ); - - private static Func NewFactory() - { - var constructor = Type.OpenApiDocumentProvider.GetConstructors().Single(); - var serviceProvider = Parameter( typeof( IServiceProvider ), "serviceProvider" ); - var body = Expression.New( constructor, serviceProvider ); - var lambda = Lambda>( body, serviceProvider ); - - return lambda.Compile(); - } - } - - public static class NamedService - { - private static readonly Func factory = NewFactory(); - - public static object New( string name ) => factory( name ); - - private static Func NewFactory() - { - var constructor = Type.NamedService.GetConstructors().Single(); - var name = Parameter( typeof( string ), "name" ); - var body = Expression.New( constructor, name ); - var lambda = Lambda>( body, name ); - - return lambda.Compile(); - } - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Property.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Property.cs deleted file mode 100644 index b8c7cec0..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Property.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.OpenApi.Reflection; - -using Microsoft.AspNetCore.OpenApi; -using static System.Linq.Expressions.Expression; -using static System.Reflection.BindingFlags; - -// HACK: all of these properties are internal in Microsoft.AspNetCore.OpenApi -// REF: https://github.com/dotnet/aspnetcore/tree/main/src/OpenApi/src -internal static class Property -{ - private static readonly Action setDocumentName = NewSetDocumentName(); - - extension( OpenApiOptions options ) - { - public void SetDocumentName( string value ) => setDocumentName( options, value ); - } - - private static Action NewSetDocumentName() - { - var options = Parameter( typeof( OpenApiOptions ), "options" ); - var documentName = Parameter( typeof( string ), "documentName" ); - var property = typeof( OpenApiOptions ).GetProperty( nameof( OpenApiOptions.DocumentName ), Instance | NonPublic | Public )!; - var body = Assign( Property( options, property ), documentName ); - var lambda = Lambda>( body, options, documentName ); - - return lambda.Compile(); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs deleted file mode 100644 index 14e85ea8..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Reflection/Type.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.OpenApi.Reflection; - -using static System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes; - -// HACK: all of these types are internal in Microsoft.AspNetCore.OpenApi -// REF: https://github.com/dotnet/aspnetcore/tree/main/src/OpenApi/src -internal static class Type -{ - [DynamicallyAccessedMembers( PublicConstructors )] - public static readonly System.Type IDocumentProvider = System.Type.GetType( "Microsoft.Extensions.ApiDescriptions.IDocumentProvider, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; - - [DynamicallyAccessedMembers( PublicConstructors )] - public static readonly System.Type NamedService = System.Type.GetType( "Microsoft.AspNetCore.OpenApi.NamedService`1[[Microsoft.AspNetCore.OpenApi.OpenApiDocumentService, Microsoft.AspNetCore.OpenApi]], Microsoft.AspNetCore.OpenApi", throwOnError: true )!; - - 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 )!; - - [DynamicallyAccessedMembers( PublicConstructors )] - public static readonly System.Type OpenApiDocumentProvider = System.Type.GetType( "Microsoft.Extensions.ApiDescriptions.OpenApiDocumentProvider, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; - - [DynamicallyAccessedMembers( PublicConstructors )] - public static readonly System.Type OpenApiDocumentService = System.Type.GetType( "Microsoft.AspNetCore.OpenApi.OpenApiDocumentService, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; - - [DynamicallyAccessedMembers( PublicConstructors )] - public static readonly System.Type OpenApiSchemaService = System.Type.GetType( "Microsoft.AspNetCore.OpenApi.OpenApiSchemaService, Microsoft.AspNetCore.OpenApi", throwOnError: true )!; -} \ No newline at end of file From 888f90e5ddcda686eb39c3b21c315740b7761dd8 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Tue, 12 May 2026 16:39:46 +0200 Subject: [PATCH 2/4] Make it work for build-time --- .../MinimalOpenApiExample.csproj | 2 - .../IEndpointConventionBuilderExtensions.cs | 6 +- .../Configuration/ConfigureOpenApiOptions.cs | 2 - .../AggregateKeyedServiceProvider.cs | 103 +++++++++++++----- .../IApiVersioningBuilderExtensions.cs | 81 +++++++++++--- 5 files changed, 139 insertions(+), 55 deletions(-) diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj b/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj index bd6caa8b..0c742552 100644 --- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj +++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/MinimalOpenApiExample.csproj @@ -4,8 +4,6 @@ net10.0 Example API true - false - false diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs index 730f21c9..c0a55e9e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Builder/IEndpointConventionBuilderExtensions.cs @@ -9,6 +9,7 @@ namespace Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; /// /// Provides extension methods for . @@ -43,12 +44,11 @@ private static void ApplyApiVersioning( EndpointBuilder builder ) private static Task InterceptRequestServices( HttpContext context, RequestDelegate action ) { - if ( context.RequestServices is not AggregateKeyedServiceProvider serviceProvider ) + if ( context.RequestServices is not AggregateKeyedServiceProvider ) { - serviceProvider = context.RequestServices.GetRequiredService(); + context.RequestServices = context.RequestServices.GetRequiredService().Services; } - context.RequestServices = serviceProvider; return action( context ); } } \ No newline at end of file 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 aef29c1d..a99a4839 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/ConfigureOpenApiOptions.cs @@ -8,8 +8,6 @@ namespace Asp.Versioning.OpenApi.Configuration; using Asp.Versioning.OpenApi.Transformers; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; -using System.Net; -using System.Runtime.CompilerServices; internal sealed class ConfigureOpenApiOptions( XmlCommentsTransformer xmlComments, diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs index 2fd7c997..b54beaea 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs @@ -4,57 +4,100 @@ namespace Microsoft.Extensions.DependencyInjection; -internal sealed class AggregateKeyedServiceProvider( IServiceProvider parent ) : IKeyedServiceProvider +using Asp.Versioning.ApiExplorer; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +internal sealed class AggregateKeyedServiceProvider : IKeyedServiceProvider, IDisposable { - private readonly IServiceProvider parent = parent; - private readonly List providers = []; + private readonly IServiceCollection services; + private readonly SemaphoreSlim semaphore = new SemaphoreSlim( 1, 1 ); - public object? GetKeyedService( Type serviceType, object? serviceKey ) + private IServiceProvider serviceProvider; + private bool initialized; + private int? initializingThreadId; + + public AggregateKeyedServiceProvider( IServiceProvider serviceProvider, IServiceCollection services ) { - if ( providers.Count == 0 ) - { - return parent.GetKeyedService( serviceType, serviceKey ); - } + this.services = services; + this.serviceProvider = serviceProvider; + var lifetime = serviceProvider.GetRequiredService(); + lifetime.ApplicationStarted.Register( () => EnsureInitialized(true) ); + } - foreach ( var provider in providers ) + private IServiceProvider ServiceProvider + { + get { - if ( provider.GetKeyedService( serviceType, serviceKey ) is { } service ) - { - return service; - } + EnsureInitialized(false); + return serviceProvider; } - - return null; } - public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) + private void EnsureInitialized(bool isReady) { - if ( providers.Count == 0 ) + // If already initialized, we can return immediately. + if ( initialized) + { + return; + } + + if ( initializingThreadId.HasValue && Environment.CurrentManagedThreadId == initializingThreadId.Value ) { - return parent.GetRequiredKeyedService( serviceType, serviceKey ); + return; } - for ( int i = 0; i < providers.Count - 1; i++ ) + // If a "ready" call entered this call already, ensure that other calls will be blocked until we fully initialize. + semaphore.Wait(); + try { - if ( providers[i].GetKeyedService( serviceType, serviceKey ) is { } service ) + if ( initialized || !isReady ) + { + return; + } + + initializingThreadId = Environment.CurrentManagedThreadId; + var provider = serviceProvider.GetRequiredService(); + + var collection = new ServiceCollection(); + foreach ( var descriptor in services ) { - return service; + collection.Add( descriptor ); } + + var descriptions = provider.ApiVersionDescriptions; + + for ( var i = 0; i < descriptions.Count; i++ ) + { + var description = descriptions[i]; + collection.AddOpenApi( description.GroupName ); + } + + serviceProvider = collection.BuildServiceProvider(); + initialized = true; + initializingThreadId = null; } + finally + { + semaphore.Release(); + } + } - return providers[providers.Count - 1].GetRequiredKeyedService( serviceType, serviceKey ); + public object? GetKeyedService( Type serviceType, object? serviceKey ) + { + return ServiceProvider.GetKeyedService( serviceType, serviceKey ); + } + + public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) + { + return ServiceProvider.GetRequiredKeyedService( serviceType, serviceKey ); } public object? GetService( Type serviceType ) - => parent.GetService( serviceType ); + => ServiceProvider.GetService( serviceType ); - public void Add( IServiceCollection serviceCollection, IServiceCollection parentServiceCollection ) + public void Dispose() { - foreach ( var descriptor in parentServiceCollection ) - { - serviceCollection.Add( descriptor ); - } - - providers.Add( serviceCollection.BuildServiceProvider() ); + semaphore.Dispose(); } } \ No newline at end of file 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 f861ed56..c577999f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -5,16 +5,13 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; -using Asp.Versioning.ApiExplorer; using Asp.Versioning.OpenApi; using Asp.Versioning.OpenApi.Configuration; using Asp.Versioning.OpenApi.Transformers; -using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; -using System.ComponentModel.Design; using System.Diagnostics; using System.Reflection; using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; @@ -65,7 +62,18 @@ private static void AddOpenApiServices( IApiVersioningBuilder builder, Assembly[ var services = builder.Services; - services.AddTransient( serviceProvider => NewRequestServices( serviceProvider, services ) ); + services.Add( GetDocumentProviderDescriptor() ); + + var hostDescriptor = services.Single( + s => !s.IsKeyedService && + s.ServiceType == typeof( IHost ) && + s.Lifetime == ServiceLifetime.Singleton && + s.ImplementationInstance is null && + s.ImplementationType is null && + s.ImplementationFactory is not null ); + var hostDescriptorIndex = services.IndexOf( hostDescriptor ); + + builder.Services[hostDescriptorIndex] = CreateHostWrapperDescriptor( services, hostDescriptor.ImplementationFactory! ); services.AddSingleton(); services.TryAddEnumerable( Transient, ConfigureOpenApiOptions>() ); @@ -74,6 +82,56 @@ private static void AddOpenApiServices( IApiVersioningBuilder builder, Assembly[ services.TryAddTransient( sp => new XmlCommentsTransformer( sp.GetRequiredService() ) ); } + private static ServiceDescriptor GetDocumentProviderDescriptor() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddOpenApi(); + foreach ( var descriptor in serviceCollection ) + { + if ( descriptor.ServiceType.FullName == "Microsoft.Extensions.ApiDescriptions.IDocumentProvider" ) + { + return descriptor; + } + } + + throw new UnreachableException(); + } + + private static ServiceDescriptor CreateHostWrapperDescriptor( IServiceCollection serviceCollection, Func hostFactory ) + { + Func updatedHostFactory = serviceProvider => + { + var originalHost = (IHost) hostFactory( serviceProvider ); + return new OpenApiHost(originalHost, NewRequestServices(serviceProvider, serviceCollection)); + }; + + return new ServiceDescriptor( typeof( IHost ), updatedHostFactory, ServiceLifetime.Singleton ); + } + + private sealed class OpenApiHost : IHost + { + private readonly IHost originalHost; + private readonly IServiceProvider customServiceProvider; + + public OpenApiHost( IHost originalHost, IServiceProvider customServiceProvider ) + { + this.originalHost = originalHost; + this.customServiceProvider = customServiceProvider; + } + + public IServiceProvider Services + => customServiceProvider; + + public void Dispose() + => originalHost.Dispose(); + + public Task StartAsync( CancellationToken cancellationToken = default ) + => originalHost.StartAsync( cancellationToken ); + + public Task StopAsync( CancellationToken cancellationToken = default ) + => originalHost.StopAsync( cancellationToken ); + } + // NOTE: The calling assembly must be captured at the call site that invokes AddOpenApi. In 99% of the cases that // should be the entry point to the application. It is technically possible to be invoked from some other assembly - // perhaps another extension library. If that were to happen, that library must resolve the path on its own and @@ -90,21 +148,8 @@ private static Assembly[] GetAssemblies( Assembly callingAssembly ) return [.. assemblies]; } - [UnconditionalSuppressMessage( "ILLink", "IL3050" )] private static AggregateKeyedServiceProvider NewRequestServices( IServiceProvider services, IServiceCollection parentServiceCollection ) { - var provider = services.GetRequiredService(); - var container = new AggregateKeyedServiceProvider( services ); - var descriptions = provider.ApiVersionDescriptions; - - for ( var i = 0; i < descriptions.Count; i++ ) - { - var description = descriptions[i]; - var serviceCollection = new ServiceCollection(); - serviceCollection.AddOpenApi( description.GroupName ); - container.Add( serviceCollection, parentServiceCollection ); - } - - return container; + return new AggregateKeyedServiceProvider( services, parentServiceCollection ); } } \ No newline at end of file From bcaf2e8c28d31d6c876c2b092aa6ed1ab9c0f1c3 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Wed, 13 May 2026 19:49:58 +0200 Subject: [PATCH 3/4] Adjust --- .../AggregateKeyedServiceProvider.cs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs index b54beaea..5c0fd73b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/DependencyInjection/AggregateKeyedServiceProvider.cs @@ -12,32 +12,33 @@ internal sealed class AggregateKeyedServiceProvider : IKeyedServiceProvider, IDi { private readonly IServiceCollection services; private readonly SemaphoreSlim semaphore = new SemaphoreSlim( 1, 1 ); - - private IServiceProvider serviceProvider; + private readonly IServiceProvider originalServiceProvider; + private IServiceProvider activeServiceProvider; private bool initialized; private int? initializingThreadId; public AggregateKeyedServiceProvider( IServiceProvider serviceProvider, IServiceCollection services ) { this.services = services; - this.serviceProvider = serviceProvider; + originalServiceProvider = serviceProvider; + activeServiceProvider = serviceProvider; var lifetime = serviceProvider.GetRequiredService(); - lifetime.ApplicationStarted.Register( () => EnsureInitialized(true) ); + lifetime.ApplicationStarted.Register( () => EnsureInitialized( true ) ); } private IServiceProvider ServiceProvider { get { - EnsureInitialized(false); - return serviceProvider; + EnsureInitialized( false ); + return activeServiceProvider; } } - private void EnsureInitialized(bool isReady) + private void EnsureInitialized( bool isReady ) { // If already initialized, we can return immediately. - if ( initialized) + if ( initialized ) { return; } @@ -57,7 +58,7 @@ private void EnsureInitialized(bool isReady) } initializingThreadId = Environment.CurrentManagedThreadId; - var provider = serviceProvider.GetRequiredService(); + var provider = activeServiceProvider.GetRequiredService(); var collection = new ServiceCollection(); foreach ( var descriptor in services ) @@ -73,7 +74,7 @@ private void EnsureInitialized(bool isReady) collection.AddOpenApi( description.GroupName ); } - serviceProvider = collection.BuildServiceProvider(); + activeServiceProvider = collection.BuildServiceProvider(); initialized = true; initializingThreadId = null; } @@ -94,7 +95,7 @@ public object GetRequiredKeyedService( Type serviceType, object? serviceKey ) } public object? GetService( Type serviceType ) - => ServiceProvider.GetService( serviceType ); + => originalServiceProvider.GetService( serviceType ) ?? ServiceProvider.GetService( serviceType ); public void Dispose() { From b492d06103507e551459c32f962e134e62a340a0 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Wed, 13 May 2026 20:06:03 +0200 Subject: [PATCH 4/4] Fix warnings that are now occurring --- .../Conventions/ODataValidationSettingsConventionTest.cs | 4 ++++ .../Routing/DefaultMetadataMatcherPolicyTest.cs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs index 85fd4cce..926883e4 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Conventions/ODataValidationSettingsConventionTest.cs @@ -621,6 +621,7 @@ public void apply_to_should_process_odataX2Dlike_api_description() model.SetAnnotationValue( model, new ApiVersionAnnotation( ApiVersion.Default ) ); +#pragma warning disable CA1825 // Avoid zero-length array allocations return new() { ActionDescriptor = new ControllerActionDescriptor() @@ -643,6 +644,7 @@ public void apply_to_should_process_odataX2Dlike_api_description() }, Properties = { [typeof( ApiVersion )] = ApiVersion.Default }, }; +#pragma warning restore CA1825 // Avoid zero-length array allocations } private static ApiDescription NewApiDescription( Type controllerType ) => @@ -652,6 +654,7 @@ private static ApiDescription NewApiDescription( Type controllerType, Type respo { model.SetAnnotationValue( model, new ApiVersionAnnotation( ApiVersion.Default ) ); +#pragma warning disable CA1825 // Avoid zero-length array allocations return new() { ActionDescriptor = new ControllerActionDescriptor() @@ -674,6 +677,7 @@ private static ApiDescription NewApiDescription( Type controllerType, Type respo }, Properties = { [typeof( ApiVersion )] = ApiVersion.Default }, }; +#pragma warning restore CA1825 // Avoid zero-length array allocations } #pragma warning disable IDE0060 // Remove unused parameter diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Routing/DefaultMetadataMatcherPolicyTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Routing/DefaultMetadataMatcherPolicyTest.cs index 0d2bd095..cec56af6 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Routing/DefaultMetadataMatcherPolicyTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/Routing/DefaultMetadataMatcherPolicyTest.cs @@ -17,7 +17,9 @@ public void applies_to_endpoints_should_return_true_for_service_document() var paramSource = Mock.Of(); var options = Options.Create( new ApiVersioningOptions() ); var policy = new DefaultMetadataMatcherPolicy( paramSource, options ); +#pragma warning disable CA1825 // Avoid zero-length array allocations var metadata = new ODataRoutingMetadata( string.Empty, EdmCoreModel.Instance, [] ); +#pragma warning restore CA1825 // Avoid zero-length array allocations var items = new object[] { metadata }; var endpoints = new Endpoint[] { new( Limbo, new( items ), default ) };