diff --git a/AspNetCore.sln b/AspNetCore.sln index 4b0b6d8c126e..1eb66536ed54 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1790,6 +1790,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.WasmRemoteAuthen EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "src\OpenApi\sample\Sample.csproj", "{6DEC24A8-A166-432F-8E3B-58FFCDA92F52}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Microbenchmarks", "src\OpenApi\perf\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", "{CF082AD5-E513-4A27-A6C7-3767E04D6205}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10807,6 +10809,22 @@ Global {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x64.Build.0 = Release|Any CPU {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x86.ActiveCfg = Release|Any CPU {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x86.Build.0 = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|arm64.ActiveCfg = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|arm64.Build.0 = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|x64.ActiveCfg = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|x64.Build.0 = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|x86.ActiveCfg = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Debug|x86.Build.0 = Debug|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|Any CPU.Build.0 = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|arm64.ActiveCfg = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|arm64.Build.0 = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|x64.ActiveCfg = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|x64.Build.0 = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|x86.ActiveCfg = Release|Any CPU + {CF082AD5-E513-4A27-A6C7-3767E04D6205}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11691,6 +11709,7 @@ Global {433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995} {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995} {6DEC24A8-A166-432F-8E3B-58FFCDA92F52} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} + {CF082AD5-E513-4A27-A6C7-3767E04D6205} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs index dda21878323f..adc9de0999af 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs @@ -12,8 +12,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Diagnostics.Runtime.Interop; namespace Microsoft.AspNetCore.Http.Generators.Tests; diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf index ca74ac85ba10..bd76e9c389c3 100644 --- a/src/OpenApi/OpenApi.slnf +++ b/src/OpenApi/OpenApi.slnf @@ -9,7 +9,8 @@ "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", "src\\OpenApi\\src\\Microsoft.AspNetCore.OpenApi.csproj", "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj", - "src\\OpenApi\\sample\\Sample.csproj" + "src\\OpenApi\\sample\\Sample.csproj", + "src\\OpenApi\\perf\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj" ] } } diff --git a/src/OpenApi/perf/AssemblyInfo.cs b/src/OpenApi/perf/AssemblyInfo.cs new file mode 100644 index 000000000000..09f49228e9e6 --- /dev/null +++ b/src/OpenApi/perf/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/src/OpenApi/perf/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj b/src/OpenApi/perf/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj new file mode 100644 index 000000000000..5bb2fb1dc6bf --- /dev/null +++ b/src/OpenApi/perf/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj @@ -0,0 +1,23 @@ + + + + $(DefaultNetCoreTargetFramework) + Exe + true + + + + + + + + + + + + + + + + + diff --git a/src/OpenApi/perf/TransformersBenchmark.cs b/src/OpenApi/perf/TransformersBenchmark.cs new file mode 100644 index 000000000000..4dd02d9989d0 --- /dev/null +++ b/src/OpenApi/perf/TransformersBenchmark.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; + +/// +/// The following benchmarks are used to assess the memory and performance +/// impact of different types of transformers. In particular, we want to +/// measure the impact of (a) context-object creation and caching and (b) +/// enumerator usage when processing operations in a given document. +/// +public class TransformersBenchmark : OpenApiDocumentServiceTestBase +{ + [Params(10, 100, 1000)] + public int TransformerCount { get; set; } + + private readonly IEndpointRouteBuilder _builder = CreateBuilder(); + private readonly OpenApiOptions _options = new OpenApiOptions(); + private OpenApiDocumentService _documentService; + + [GlobalSetup(Target = nameof(OperationTransformerAsDelegate))] + public void OperationTransformerAsDelegate_Setup() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseOperationTransformer((operation, context, token) => + { + operation.Description = "New Description"; + return Task.CompletedTask; + }); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [GlobalSetup(Target = nameof(ActivatedDocumentTransformer))] + public void ActivatedDocumentTransformer_Setup() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseTransformer(); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [GlobalSetup(Target = nameof(DocumentTransformerAsDelegate))] + public void DocumentTransformerAsDelegate_Delegate() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseTransformer((document, context, token) => + { + document.Info.Description = "New Description"; + return Task.CompletedTask; + }); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [Benchmark] + public async Task OperationTransformerAsDelegate() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + [Benchmark] + public async Task ActivatedDocumentTransformer() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + [Benchmark] + public async Task DocumentTransformerAsDelegate() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + private class ActivatedTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } +} diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 591fc2fcb8ed..8176afc6fccb 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,10 +1,25 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.OpenApi.Models; +using Sample.Transformers; + var builder = WebApplication.CreateBuilder(args); -builder.Services.AddOpenApi("v1"); -builder.Services.AddOpenApi("v2"); +builder.Services.AddAuthentication().AddJwtBearer(); + +builder.Services.AddOpenApi("v1", options => +{ + options.AddHeader("X-Version", "1.0"); + options.UseTransformer(); +}); +builder.Services.AddOpenApi("v2", options => { + options.UseTransformer(new AddContactTransformer()); + options.UseTransformer((document, context, token) => { + document.Info.License = new OpenApiLicense { Name = "MIT" }; + return Task.CompletedTask; + }); +}); var app = builder.Build(); diff --git a/src/OpenApi/sample/Sample.csproj b/src/OpenApi/sample/Sample.csproj index 1cbc9b0ff713..f05bd6e2d45c 100644 --- a/src/OpenApi/sample/Sample.csproj +++ b/src/OpenApi/sample/Sample.csproj @@ -8,6 +8,7 @@ + diff --git a/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs new file mode 100644 index 000000000000..02bd033b7628 --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Sample.Transformers; + +public sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + var requirements = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", // "bearer" refers to the header name here + In = ParameterLocation.Header, + BearerFormat = "Json Web Token" + } + }; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = requirements; + } + } +} diff --git a/src/OpenApi/sample/Transformers/AddContactTransformer.cs b/src/OpenApi/sample/Transformers/AddContactTransformer.cs new file mode 100644 index 000000000000..5d9d35f1c7ad --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddContactTransformer.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Sample.Transformers; + +public sealed class AddContactTransformer : IOpenApiDocumentTransformer +{ + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Contact = new OpenApiContact + { + Name = "OpenAPI Enthusiast", + Email = "iloveopenapi@example.com" + }; + return Task.CompletedTask; + } +} diff --git a/src/OpenApi/sample/Transformers/OperationTransformers.cs b/src/OpenApi/sample/Transformers/OperationTransformers.cs new file mode 100644 index 000000000000..71b2c9842951 --- /dev/null +++ b/src/OpenApi/sample/Transformers/OperationTransformers.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Extensions; + +namespace Sample.Transformers; + +public static class OperationTransformers +{ + public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue) + { + return options.UseOperationTransformer((operation, context, cancellationToken) => + { + var schema = OpenApiTypeMapper.MapTypeToOpenApiPrimitiveType(typeof(string)); + schema.Default = new OpenApiString(defaultValue); + operation.Parameters.Add(new OpenApiParameter + { + Name = headerName, + In = ParameterLocation.Header, + Schema = schema + }); + return Task.CompletedTask; + }); + } +} diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index 052bc13a2554..7bae09542251 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -43,7 +43,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e } else { - var document = await documentService.GetOpenApiDocumentAsync(); + var document = await documentService.GetOpenApiDocumentAsync(context.RequestAborted); var documentOptions = options.Get(documentName); using var output = MemoryBufferWriter.Get(); using var writer = Utf8BufferTextWriter.Get(output); diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index ac447a7cc31d..9ff56f9d119c 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -27,6 +27,7 @@ + diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index 76a7e128784b..f32c9da9334c 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -1,5 +1,8 @@ #nullable enable Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions +Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer.TransformAsync(Microsoft.OpenApi.Models.OpenApiDocument! document, Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Description.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Description.init -> void Microsoft.AspNetCore.OpenApi.OpenApiOptions Microsoft.AspNetCore.OpenApi.OpenApiOptions.DocumentName.get -> string! Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiOptions() -> void @@ -7,9 +10,28 @@ Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.get -> Microsoft.Open Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.set -> void Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.get -> System.Func! Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.set -> void +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseOperationTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions static Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.MapOpenApi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern = "/openapi/{documentName}.json") -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.ApplicationServices.get -> System.IServiceProvider! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.ApplicationServices.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DescriptionGroups.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DescriptionGroups.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DocumentName.get -> string! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DocumentName.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.OpenApiDocumentTransformerContext() -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.ApplicationServices.get -> System.IServiceProvider! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.ApplicationServices.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.DocumentName.get -> string! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.DocumentName.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.OpenApiOperationTransformerContext() -> void diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index 801493beb2fb..f12acc4737a6 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -8,4 +8,5 @@ internal static class OpenApiConstants internal const string DefaultDocumentName = "v1"; internal const string DefaultOpenApiVersion = "1.0.0"; internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json"; + internal const string DescriptionId = "x-aspnetcore-id"; } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 0440b0993d8b..aa8bd0509c3f 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -1,13 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi; @@ -16,11 +19,23 @@ internal sealed class OpenApiDocumentService( [ServiceKey] string documentName, IApiDescriptionGroupCollectionProvider apiDescriptionGroupCollectionProvider, IHostEnvironment hostEnvironment, - IOptionsMonitor optionsMonitor) + IOptionsMonitor optionsMonitor, + IServiceProvider serviceProvider) { private readonly OpenApiOptions _options = optionsMonitor.Get(documentName); - public Task GetOpenApiDocumentAsync() + /// + /// Cache of instances keyed by the + /// `ApiDescription.ActionDescriptor.Id` of the associated operation. ActionDescriptor IDs + /// are unique within the lifetime of an application and serve as helpful associators between + /// operations, API descriptions, and their respective transformer contexts. + /// + private readonly ConcurrentDictionary _operationTransformerContextCache = new(); + + internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) + => _operationTransformerContextCache.TryGetValue(descriptionId, out context); + + public async Task GetOpenApiDocumentAsync(CancellationToken cancellationToken = default) { // For good hygiene, operation-level tags must also appear in the document-level // tags collection. This set captures all tags that have been seen so far. @@ -31,7 +46,24 @@ public Task GetOpenApiDocumentAsync() Paths = GetOpenApiPaths(capturedTags), Tags = [.. capturedTags] }; - return Task.FromResult(document); + await ApplyTransformersAsync(document, cancellationToken); + return document; + } + + private async Task ApplyTransformersAsync(OpenApiDocument document, CancellationToken cancellationToken) + { + var documentTransformerContext = new OpenApiDocumentTransformerContext + { + DocumentName = documentName, + ApplicationServices = serviceProvider, + DescriptionGroups = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items, + }; + // Use index-based for loop to avoid allocating an enumerator with a foreach. + for (var i = 0; i < _options.DocumentTransformers.Count; i++) + { + var transformer = _options.DocumentTransformers[i]; + await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); + } } // Note: Internal for testing. @@ -68,12 +100,20 @@ private OpenApiPaths GetOpenApiPaths(HashSet capturedTags) return paths; } - private static Dictionary GetOperations(IGrouping descriptions, HashSet capturedTags) + private Dictionary GetOperations(IGrouping descriptions, HashSet capturedTags) { var operations = new Dictionary(); foreach (var description in descriptions) { - operations[description.GetOperationType()] = GetOperation(description, capturedTags); + var operation = GetOperation(description, capturedTags); + operation.Extensions.Add(OpenApiConstants.DescriptionId, new OpenApiString(description.ActionDescriptor.Id)); + _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, new OpenApiOperationTransformerContext + { + DocumentName = documentName, + Description = description, + ApplicationServices = serviceProvider, + }); + operations[description.GetOperationType()] = operation; } return operations; } diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index efe92b30dfb5..fba727315660 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.OpenApi; +using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi; @@ -11,6 +13,8 @@ namespace Microsoft.AspNetCore.OpenApi; /// public sealed class OpenApiOptions { + internal readonly List DocumentTransformers = []; + /// /// Initializes a new instance of the class /// with the default predicate. @@ -34,4 +38,55 @@ public OpenApiOptions() /// A delegate to determine whether a given should be included in the given OpenAPI document. /// public Func ShouldInclude { get; set; } + + /// + /// Registers a new document transformer on the current instance. + /// + /// The type of the to instantiate. + /// The instance for further customization. + public OpenApiOptions UseTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>() + where TTransformerType : IOpenApiDocumentTransformer + { + DocumentTransformers.Add(new TypeBasedOpenApiDocumentTransformer(typeof(TTransformerType))); + return this; + } + + /// + /// Registers a given instance of on the current instance. + /// + /// The instance to use. + /// The instance for further customization. + public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + DocumentTransformers.Add(transformer); + return this; + } + + /// + /// Registers a given delegate as a document transformer on the current instance. + /// + /// The delegate representing the document transformer. + /// The instance for further customization. + public OpenApiOptions UseTransformer(Func transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); + return this; + } + + /// + /// Registers a given delegate as an operation transformer on the current instance. + /// + /// The delegate representing the operation transformer. + /// The instance for further customization. + public OpenApiOptions UseOperationTransformer(Func transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); + return this; + } } diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..63db26908d40 --- /dev/null +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class DelegateOpenApiDocumentTransformer : IOpenApiDocumentTransformer +{ + // Since there's a finite set of operation types that can be included in a given + // OpenApiPaths, we can pre-allocate an array of these types and use a direct + // lookup on the OpenApiPaths dictionary to avoid allocating an enumerator + // over the KeyValuePairs in OpenApiPaths. + private static readonly OperationType[] _operationTypes = [ + OperationType.Get, + OperationType.Post, + OperationType.Put, + OperationType.Delete, + OperationType.Options, + OperationType.Head, + OperationType.Patch, + OperationType.Trace + ]; + private readonly Func? _documentTransformer; + private readonly Func? _operationTransformer; + + public DelegateOpenApiDocumentTransformer(Func transformer) + { + _documentTransformer = transformer; + } + + public DelegateOpenApiDocumentTransformer(Func transformer) + { + _operationTransformer = transformer; + } + + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + if (_documentTransformer != null) + { + await _documentTransformer(document, context, cancellationToken); + } + + if (_operationTransformer != null) + { + var documentService = context.ApplicationServices.GetRequiredKeyedService(context.DocumentName); + foreach (var pathItem in document.Paths.Values) + { + for (var i = 0; i < _operationTypes.Length; i++) + { + var operationType = _operationTypes[i]; + if (!pathItem.Operations.TryGetValue(operationType, out var operation)) + { + continue; + } + + if (operation.Extensions.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionIdExtension) && + descriptionIdExtension is OpenApiString { Value: var descriptionId } && + documentService.TryGetCachedOperationTransformerContext(descriptionId, out var operationContext)) + { + await _operationTransformer(operation, operationContext, cancellationToken); + } + else + { + // If the cached operation transformer context was not found, throw an exception. + // This can occur if the `x-aspnetcore-id` extension attribute was removed by the + // user in another operation transformer or if the lookup for operation transformer + // context resulted in a cache miss. As an alternative here, we could just to implement + // the "slow-path" and look up the ApiDescription associated with the OpenApiOperation + // using the OperationType and given path, but we'll avoid this for now. + throw new InvalidOperationException("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute."); + } + } + } + } + } +} diff --git a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..c12f249a1939 --- /dev/null +++ b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents a transformer that can be used to modify an OpenAPI document. +/// +public interface IOpenApiDocumentTransformer +{ + /// + /// Transforms the specified OpenAPI document. + /// + /// The to modify. + /// The associated with the . + /// The cancellation token to use. + /// The task object representing the asynchronous operation. + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken); +} diff --git a/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs new file mode 100644 index 000000000000..c47638bedf93 --- /dev/null +++ b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents the context in which an OpenAPI document transformer is executed. +/// +public sealed class OpenApiDocumentTransformerContext +{ + /// + /// Gets the name of the associated OpenAPI document. + /// + public required string DocumentName { get; init; } + + /// + /// Gets the API description groups associated with current document. + /// + public required IReadOnlyList DescriptionGroups { get; init; } + + /// + /// Gets the application services associated with current document. + /// + public required IServiceProvider ApplicationServices { get; init; } +} diff --git a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs new file mode 100644 index 000000000000..49d76a0191e6 --- /dev/null +++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents the context in which an OpenAPI operation transformer is executed. +/// +public sealed class OpenApiOperationTransformerContext +{ + /// + /// Gets the name of the associated OpenAPI document. + /// + public required string DocumentName { get; init; } + + /// + /// Gets the API description associated with target operation. + /// + public required ApiDescription Description { get; init; } + + /// + /// Gets the application services associated with the current document the target operation is in. + /// + public required IServiceProvider ApplicationServices { get; init; } +} diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..4883b38bce76 --- /dev/null +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class TypeBasedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer +{ + private readonly ObjectFactory _transformerFactory = ActivatorUtilities.CreateFactory(transformerType, []); + + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiDocumentTransformer; + Debug.Assert(transformer != null, $"The type {transformerType} does not implement {nameof(IOpenApiDocumentTransformer)}."); + try + { + await transformer.TransformAsync(document, context, cancellationToken); + } + finally + { + if (transformer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (transformer is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj index 71abb72047ff..edd26efec6e1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs index e8d3aa13803f..3145eb5c4553 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Options; using Moq; @@ -21,7 +22,8 @@ public void GetOpenApiInfo_RespectsHostEnvironmentName() "v1", new Mock().Object, hostEnvironment, - new Mock>().Object); + new Mock>().Object, + new Mock().Object); // Act var info = docService.GetOpenApiInfo(); @@ -42,7 +44,8 @@ public void GetOpenApiInfo_RespectsDocumentName() "v2", new Mock().Object, hostEnvironment, - new Mock>().Object); + new Mock>().Object, + new Mock().Object); // Act var info = docService.GetOpenApiInfo(); diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs index 700c45880af2..c517dd7e9bd2 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Moq; @@ -13,6 +14,16 @@ public abstract class OpenApiDocumentServiceTestBase { public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action verifyOpenApiDocument) + => await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument); + + public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions, Action verifyOpenApiDocument) + { + var documentService = CreateDocumentService(builder, openApiOptions); + var document = await documentService.GetOpenApiDocumentAsync(); + verifyOpenApiDocument(document); + } + + internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions) { var context = new ApiDescriptionProviderContext([]); @@ -22,7 +33,7 @@ public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Ac ApplicationName = nameof(OpenApiDocumentServiceTests) }; var options = new Mock>(); - options.Setup(o => o.Get(It.IsAny())).Returns(new OpenApiOptions()); + options.Setup(o => o.Get(It.IsAny())).Returns(openApiOptions); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); provider.OnProvidersExecuting(context); @@ -30,9 +41,10 @@ public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Ac var apiDescriptionGroupCollectionProvider = CreateApiDescriptionGroupCollectionProvider(context.Results); - var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object); - var document = await documentService.GetOpenApiDocumentAsync(); - verifyOpenApiDocument(document); + var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, builder.ServiceProvider); + ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; + + return documentService; } public static IApiDescriptionGroupCollectionProvider CreateApiDescriptionGroupCollectionProvider(IList apiDescriptions = null) @@ -50,8 +62,12 @@ public static IApiDescriptionGroupCollectionProvider CreateApiDescriptionGroupCo new DefaultParameterPolicyFactory(Options.Create(new RouteOptions()), new TestServiceProvider()), new ServiceProviderIsService()); - internal static TestEndpointRouteBuilder CreateBuilder() => - new TestEndpointRouteBuilder(new ApplicationBuilder(TestServiceProvider.Instance)); + internal static TestEndpointRouteBuilder CreateBuilder(IServiceCollection serviceCollection = null) + { + var serviceProvider = new TestServiceProvider(); + serviceProvider.SetInternalServiceProvider(serviceCollection ?? new ServiceCollection()); + return new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + } internal class TestEndpointRouteBuilder : IEndpointRouteBuilder { @@ -70,9 +86,36 @@ public TestEndpointRouteBuilder(IApplicationBuilder applicationBuilder) public IServiceProvider ServiceProvider => ApplicationBuilder.ApplicationServices; } - private class TestServiceProvider : IServiceProvider + private class TestServiceProvider : IServiceProvider, IKeyedServiceProvider { public static TestServiceProvider Instance { get; } = new TestServiceProvider(); + private IKeyedServiceProvider _serviceProvider; + internal OpenApiDocumentService TestDocumentService { get; set; } + + public void SetInternalServiceProvider(IServiceCollection serviceCollection) + { + _serviceProvider = serviceCollection.BuildServiceProvider(); + } + + public object GetKeyedService(Type serviceType, object serviceKey) + { + if (serviceType == typeof(OpenApiDocumentService)) + { + return TestDocumentService; + } + + return _serviceProvider.GetKeyedService(serviceType, serviceKey); + } + + public object GetRequiredKeyedService(Type serviceType, object serviceKey) + { + if (serviceType == typeof(OpenApiDocumentService)) + { + return TestDocumentService; + } + + return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } public object GetService(Type serviceType) { @@ -81,7 +124,7 @@ public object GetService(Type serviceType) return Options.Create(new RouteHandlerOptions()); } - return null; + return _serviceProvider.GetService(serviceType); } } } diff --git a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs new file mode 100644 index 000000000000..2af401a19b0b --- /dev/null +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs @@ -0,0 +1,230 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +public class DocumentTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task DocumentTransformer_RunsInRegisteredOrder() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer((document, context, cancellationToken) => + { + document.Info.Description = "1"; + return Task.CompletedTask; + }); + options.UseTransformer((document, context, cancellationToken) => + { + Assert.Equal("1", document.Info.Description); + document.Info.Description = "2"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("2", document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsActivatedTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsInstanceTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(new ActivatedTransformer()); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsActivatedTransformerWithSingletonDependency() + { + var serviceCollection = new ServiceCollection().AddSingleton(); + var builder = CreateBuilder(serviceCollection); + + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + // Assert that singleton dependency is only instantiated once + // regardless of the number of requests. + string description = null; + await VerifyOpenApiDocument(builder, options, document => + { + description = document.Info.Description; + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal(description, document.Info.Description); + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsActivatedTransformerWithTransientDependency() + { + var serviceCollection = new ServiceCollection().AddTransient(); + var builder = CreateBuilder(serviceCollection); + + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + // Assert that transient dependency is instantiated twice for each + // request to the OpenAPI document. + string description = null; + await VerifyOpenApiDocument(builder, options, document => + { + description = document.Info.Description; + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.NotEqual(description, document.Info.Description); + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + DisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + Assert.Equal(1, DisposableTransformer.DisposeCount); + } + + [Fact] + public async Task DocumentTransformer_SupportsAsyncDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + AsyncDisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + Assert.Equal(1, AsyncDisposableTransformer.DisposeCount); + } + + private class ActivatedTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } + + private class DisposableTransformer : IOpenApiDocumentTransformer, IDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public void Dispose() + { + Disposed = true; + DisposeCount += 1; + } + + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } + + private class AsyncDisposableTransformer : IOpenApiDocumentTransformer, IAsyncDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public ValueTask DisposeAsync() + { + Disposed = true; + DisposeCount += 1; + return ValueTask.CompletedTask; + } + + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } + + private class ActivatedTransformerWithDependency(Dependency dependency) : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + dependency.TestMethod(); + document.Info.Description = Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + } + } + + private class Dependency + { + public Dependency() + { + InstantiationCount += 1; + } + + internal void TestMethod() { } + + internal static int InstantiationCount = 0; + } +} diff --git a/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs new file mode 100644 index 000000000000..dba656f4cf30 --- /dev/null +++ b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +public class OpenApiOptionsTests +{ + [Fact] + public void UseTransformer_WithDocumentTransformerDelegate() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new Func((document, context, cancellationToken) => + { + document.Info.Title = "New Title"; + return Task.CompletedTask; + }); + + // Act + var result = options.UseTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithDocumentTransformerInstance() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new TestOpenApiDocumentTransformer(); + + // Act + var result = options.UseTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.Same(transformer, insertedTransformer); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithDocumentTransformerType() + { + // Arrange + var options = new OpenApiOptions(); + + // Act + var result = options.UseTransformer(); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithOperationTransformerDelegate() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new Func((operation, context, cancellationToken) => + { + operation.Description = "New Description"; + return Task.CompletedTask; + }); + + // Act + var result = options.UseOperationTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + } + + private class TestOpenApiDocumentTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/OpenApi/test/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Transformers/OperationTransformerTests.cs new file mode 100644 index 000000000000..877d2dfd52ba --- /dev/null +++ b/src/OpenApi/test/Transformers/OperationTransformerTests.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; + +public class OperationTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task OperationTransformer_CanAccessApiDescription() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + var apiDescription = context.Description; + operation.Description = apiDescription.RelativePath; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("todo", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("user", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_RunsInRegisteredOrder() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + operation.Description = "1"; + return Task.CompletedTask; + }); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + Assert.Equal("1", operation.Description); + operation.Description = "2"; + return Task.CompletedTask; + }); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + Assert.Equal("2", operation.Description); + operation.Description = "3"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_CanMutateOperationViaDocumentTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer((document, context, cancellationToken) => + { + foreach (var pathItem in document.Paths.Values) + { + foreach (var operation in pathItem.Operations.Values) + { + operation.Description = "3"; + } + } + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_ThrowsExceptionIfDescriptionIdNotFound() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + operation.Extensions.Remove("x-aspnetcore-id"); + return Task.CompletedTask; + }); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + return Task.CompletedTask; + }); + + var exception = await Assert.ThrowsAsync(() => VerifyOpenApiDocument(builder, options, _ => { })); + Assert.Equal("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute.", exception.Message); + } +}