From 7e36fe359ef06498fef514bbbad008f958efd577 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 27 Mar 2024 16:06:32 -0700 Subject: [PATCH 01/10] Add APIs for OpenAPI document transformers --- AspNetCore.sln | 19 +++ eng/Versions.props | 2 +- src/OpenApi/OpenApi.slnf | 3 +- src/OpenApi/perf/AssemblyInfo.cs | 4 + ....AspNetCore.OpenApi.Microbenchmarks.csproj | 23 ++++ src/OpenApi/perf/TransformersBenchmark.cs | 100 ++++++++++++++ src/OpenApi/sample/Program.cs | 18 ++- src/OpenApi/sample/Sample.csproj | 1 + .../AddBearerSecuritySchemeTransformer.cs | 29 ++++ .../Transformers/AddContactTransformer.cs | 18 +++ .../Transformers/OperationTransformers.cs | 27 ++++ .../OpenApiEndpointRouteBuilderExtensions.cs | 2 +- .../src/Microsoft.AspNetCore.OpenApi.csproj | 1 + src/OpenApi/src/PublicAPI.Unshipped.txt | 22 +++ src/OpenApi/src/Services/OpenApiConstants.cs | 1 + .../src/Services/OpenApiDocumentService.cs | 56 +++++++- src/OpenApi/src/Services/OpenApiOptions.cs | 49 +++++++ .../ActivatedOpenApiDocumentTransformer.cs | 24 ++++ .../DelegateOpenApiDocumentTransformer.cs | 46 +++++++ .../IOpenApiDocumentTransformer.cs | 21 +++ .../OpenApiDocumentTransformerContext.cs | 27 ++++ .../OpenApiOperationTransformerContext.cs | 27 ++++ .../Microsoft.AspNetCore.OpenApi.Tests.csproj | 4 + .../OpenApiDocumentServiceTests.Info.cs | 7 +- .../OpenApiDocumentServiceTestsBase.cs | 45 ++++++- .../Transformers/DocumentTransformerTests.cs | 79 +++++++++++ .../test/Transformers/OpenApiOptionsTests.cs | 87 ++++++++++++ .../Transformers/OperationTransformerTests.cs | 126 ++++++++++++++++++ 28 files changed, 851 insertions(+), 17 deletions(-) create mode 100644 src/OpenApi/perf/AssemblyInfo.cs create mode 100644 src/OpenApi/perf/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj create mode 100644 src/OpenApi/perf/TransformersBenchmark.cs create mode 100644 src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs create mode 100644 src/OpenApi/sample/Transformers/AddContactTransformer.cs create mode 100644 src/OpenApi/sample/Transformers/OperationTransformers.cs create mode 100644 src/OpenApi/src/Transformers/ActivatedOpenApiDocumentTransformer.cs create mode 100644 src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs create mode 100644 src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs create mode 100644 src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs create mode 100644 src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs create mode 100644 src/OpenApi/test/Transformers/DocumentTransformerTests.cs create mode 100644 src/OpenApi/test/Transformers/OpenApiOptionsTests.cs create mode 100644 src/OpenApi/test/Transformers/OperationTransformerTests.cs 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/eng/Versions.props b/eng/Versions.props index e8a5d41ad29e..6967ad8f3905 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -290,7 +290,7 @@ 1.10.3 0.9.9 - 0.13.0 + 0.13.12 4.2.1 2.3.0 6.0.0 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..a7ac1e766728 --- /dev/null +++ b/src/OpenApi/perf/TransformersBenchmark.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection.Metadata; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; + +[MemoryDiagnoser] +[EventPipeProfiler(EventPipeProfile.GcVerbose)] +public class TransformersBenchmark : OpenApiDocumentServiceTestBase +{ + [Params(10, 100, 1000)] + public int InvocationCount { 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 <= 1000; 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 <= 1000; i++) + { + _options.UseTransformer(); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [GlobalSetup(Target = nameof(DocumentTransformerAsDelegate))] + public void DocumentTransformerAsDelegate_Delegate() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= 1000; i++) + { + _options.UseTransformer((document, context, token) => + { + document.Info.Description = "New Description"; + return Task.CompletedTask; + }); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [Benchmark] + public async Task OperationTransformerAsDelegate() + { + for (var i = 0; i < InvocationCount; i++) + { + await _documentService.GetOpenApiDocumentAsync(); + } + } + + [Benchmark] + public async Task ActivatedDocumentTransformer() + { + for (var i = 0; i < InvocationCount; i++) + { + await _documentService.GetOpenApiDocumentAsync(); + } + } + + [Benchmark] + public async Task DocumentTransformerAsDelegate() + { + for (var i = 0; i < InvocationCount; i++) + { + 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..e21f0091e2f4 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,10 +1,24 @@ // 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; + 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..4ec5649790cf --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs @@ -0,0 +1,29 @@ +// 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; + +public sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var authenticationSchemes = (authenticationSchemeProvider is not null) + ? await authenticationSchemeProvider.GetAllSchemesAsync() + : []; + var requirements = authenticationSchemes + .Where(authScheme => authScheme.Name == "Bearer") + .ToDictionary( + (authScheme) => authScheme.Name, + (authScheme) => 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..4713d19f9749 --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddContactTransformer.cs @@ -0,0 +1,18 @@ +// 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 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..67ef80bc0c7a --- /dev/null +++ b/src/OpenApi/sample/Transformers/OperationTransformers.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.OpenApi; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Any; + +public static class OperationTransformers +{ + public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue) + { + return options.UseOperationTransformer((operation, context, cancellationToken) => + { + operation.Parameters.Add(new OpenApiParameter + { + Name = headerName, + In = ParameterLocation.Header, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString(defaultValue) + } + }); + 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..b69c6c4bde1d 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -1,6 +1,7 @@ // 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.Linq; using Microsoft.AspNetCore.Http.Metadata; @@ -8,6 +9,7 @@ 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 +18,20 @@ 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 description, and their respective transformer contexts. + /// + internal ConcurrentDictionary OperationTransformerContextCache = new(); + + 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 +42,34 @@ public Task GetOpenApiDocumentAsync() Paths = GetOpenApiPaths(capturedTags), Tags = [.. capturedTags] }; - return Task.FromResult(document); + await ApplyTransformers(document, cancellationToken); + return document; + } + + private async Task ApplyTransformers(OpenApiDocument document, CancellationToken cancellationToken = default) + { + var documentTransformerContext = new OpenApiDocumentTransformerContext + { + DocumentName = documentName, + ApplicationServices = serviceProvider, + DescriptionGroups = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items, + }; + for (var i = 0; i < _options.DocumentTransformers.Count; i++) + { + var transformer = _options.DocumentTransformers[i]; + // Delayed initialization for DI-activated transformers + // until the first time they are used when we have access + // to the target service provider. + if (transformer is ActivatedOpenApiDocumentTransformer activatedTransformer) + { + activatedTransformer.Initialize(serviceProvider); + await activatedTransformer.TransformAsync(document, documentTransformerContext, cancellationToken); + } + else + { + await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); + } + } } // Note: Internal for testing. @@ -68,12 +106,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..7199098b0d9f 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,49 @@ 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 ActivatedOpenApiDocumentTransformer(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) + { + 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) + { + 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) + { + DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); + return this; + } } diff --git a/src/OpenApi/src/Transformers/ActivatedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/ActivatedOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..92de5aa44c85 --- /dev/null +++ b/src/OpenApi/src/Transformers/ActivatedOpenApiDocumentTransformer.cs @@ -0,0 +1,24 @@ +// 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 ActivatedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer +{ + internal IOpenApiDocumentTransformer? Transformer { get; set; } + + internal void Initialize(IServiceProvider serviceProvider) + { + Transformer ??= ActivatorUtilities.CreateInstance(serviceProvider, transformerType) as IOpenApiDocumentTransformer; + } + + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + Debug.Assert(Transformer != null, "Transformer should have been initialized."); + return Transformer.TransformAsync(document, context, cancellationToken); + } +} diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..a60c4cd2c8de --- /dev/null +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -0,0 +1,46 @@ +// 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 +{ + 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) + { + foreach (var operation in pathItem.Operations.Values) + { + var descriptionId = ((OpenApiString)operation.Extensions[OpenApiConstants.DescriptionId]).Value; + var operationContext = documentService.OperationTransformerContextCache[descriptionId]; + await _operationTransformer(operation, operationContext, cancellationToken); + } + } + } + } +} diff --git a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..e19941850498 --- /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..ce55261e82be --- /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 current document. + /// + public required ApiDescription Description { get; init; } + + /// + /// Gets the application services associated with current document. + /// + public required IServiceProvider ApplicationServices { get; init; } +} 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..31daf7c8f5da 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Moq; @@ -13,6 +15,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 +34,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 +42,11 @@ 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 serviceProvider = new TestServiceProvider(); + var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, serviceProvider); + serviceProvider.TestDocumentService = documentService; + + return documentService; } public static IApiDescriptionGroupCollectionProvider CreateApiDescriptionGroupCollectionProvider(IList apiDescriptions = null) @@ -70,9 +84,30 @@ 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(); + internal OpenApiDocumentService TestDocumentService { get; set; } + + public object GetKeyedService(Type serviceType, object serviceKey) + { + if (serviceType == typeof(OpenApiDocumentService)) + { + return TestDocumentService; + } + + return null; + } + + public object GetRequiredKeyedService(Type serviceType, object serviceKey) + { + if (serviceType == typeof(OpenApiDocumentService)) + { + return TestDocumentService; + } + + return null; + } public object GetService(Type serviceType) { diff --git a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs new file mode 100644 index 000000000000..9c9a29be2f01 --- /dev/null +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.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.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +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); + }); + } + + 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/test/Transformers/OpenApiOptionsTests.cs b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs new file mode 100644 index 000000000000..2ccb5a27bbb7 --- /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 + Assert.Single(options.DocumentTransformers); + Assert.IsType(options.DocumentTransformers[0]); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithDocumentTransformerInstance() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new TestOpenApiDocumentTransformer(); + + // Act + var result = options.UseTransformer(transformer); + + // Assert + Assert.Single(options.DocumentTransformers); + Assert.Same(transformer, options.DocumentTransformers[0]); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithDocumentTransformerType() + { + // Arrange + var options = new OpenApiOptions(); + + // Act + var result = options.UseTransformer(); + + // Assert + Assert.Single(options.DocumentTransformers); + Assert.IsType(options.DocumentTransformers[0]); + 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 + Assert.Single(options.DocumentTransformers); + Assert.IsType(options.DocumentTransformers[0]); + 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..16b184283a87 --- /dev/null +++ b/src/OpenApi/test/Transformers/OperationTransformerTests.cs @@ -0,0 +1,126 @@ +// 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); + }); + }); + } +} From da22fdd53917d30b5205764cf262fe32d3e7a8df Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 4 Apr 2024 12:38:16 -0700 Subject: [PATCH 02/10] Address feedback --- src/OpenApi/perf/TransformersBenchmark.cs | 6 +++++ .../Transformers/OperationTransformers.cs | 9 ++++---- src/OpenApi/src/Services/OpenApiOptions.cs | 6 +++++ .../DelegateOpenApiDocumentTransformer.cs | 19 +++++++++++++--- .../OpenApiOperationTransformerContext.cs | 2 +- .../Transformers/OperationTransformerTests.cs | 22 +++++++++++++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/OpenApi/perf/TransformersBenchmark.cs b/src/OpenApi/perf/TransformersBenchmark.cs index a7ac1e766728..42af340a23fe 100644 --- a/src/OpenApi/perf/TransformersBenchmark.cs +++ b/src/OpenApi/perf/TransformersBenchmark.cs @@ -10,6 +10,12 @@ 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. +/// [MemoryDiagnoser] [EventPipeProfiler(EventPipeProfile.GcVerbose)] public class TransformersBenchmark : OpenApiDocumentServiceTestBase diff --git a/src/OpenApi/sample/Transformers/OperationTransformers.cs b/src/OpenApi/sample/Transformers/OperationTransformers.cs index 67ef80bc0c7a..b414354aec48 100644 --- a/src/OpenApi/sample/Transformers/OperationTransformers.cs +++ b/src/OpenApi/sample/Transformers/OperationTransformers.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Extensions; public static class OperationTransformers { @@ -11,15 +12,13 @@ public static OpenApiOptions AddHeader(this OpenApiOptions options, string heade { 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 = new OpenApiSchema - { - Type = "string", - Default = new OpenApiString(defaultValue) - } + Schema = schema }); return Task.CompletedTask; }); diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index 7199098b0d9f..1e93a92bf8b9 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -58,6 +58,8 @@ public OpenApiOptions() /// The instance for further customization. public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer) { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + DocumentTransformers.Add(transformer); return this; } @@ -69,6 +71,8 @@ public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer) /// The instance for further customization. public OpenApiOptions UseTransformer(Func transformer) { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); return this; } @@ -80,6 +84,8 @@ public OpenApiOptions UseTransformer(FuncThe 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 index a60c4cd2c8de..6c717cb7485e 100644 --- a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -36,9 +36,22 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf { foreach (var operation in pathItem.Operations.Values) { - var descriptionId = ((OpenApiString)operation.Extensions[OpenApiConstants.DescriptionId]).Value; - var operationContext = documentService.OperationTransformerContextCache[descriptionId]; - await _operationTransformer(operation, operationContext, cancellationToken); + if (operation.Extensions.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionIdExtension) && + descriptionIdExtension is OpenApiString { Value: var descriptionId } && + documentService.OperationTransformerContextCache.TryGetValue(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."); + } } } } diff --git a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs index ce55261e82be..e4307e950cdf 100644 --- a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs +++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs @@ -16,7 +16,7 @@ public sealed class OpenApiOperationTransformerContext public required string DocumentName { get; init; } /// - /// Gets the API description associated with current document. + /// Gets the API description associated with target operation. /// public required ApiDescription Description { get; init; } diff --git a/src/OpenApi/test/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Transformers/OperationTransformerTests.cs index 16b184283a87..4bf00d10b67c 100644 --- a/src/OpenApi/test/Transformers/OperationTransformerTests.cs +++ b/src/OpenApi/test/Transformers/OperationTransformerTests.cs @@ -123,4 +123,26 @@ await VerifyOpenApiDocument(builder, options, document => }); }); } + + [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.", exception.Message); + } } From 6d818a5f95ed3ecfd4882503f779da0af5ac92b3 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 4 Apr 2024 14:40:19 -0700 Subject: [PATCH 03/10] Update doc comments --- src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs | 2 +- .../src/Transformers/OpenApiOperationTransformerContext.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs index e19941850498..c12f249a1939 100644 --- a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs @@ -14,7 +14,7 @@ public interface IOpenApiDocumentTransformer /// Transforms the specified OpenAPI document. /// /// The to modify. - /// The associated with the . + /// 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/OpenApiOperationTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs index e4307e950cdf..49d76a0191e6 100644 --- a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs +++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs @@ -21,7 +21,7 @@ public sealed class OpenApiOperationTransformerContext public required ApiDescription Description { get; init; } /// - /// Gets the application services associated with current document. + /// Gets the application services associated with the current document the target operation is in. /// public required IServiceProvider ApplicationServices { get; init; } } From 6bf31860967d1e1112ad6cc50d24d92910928e76 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 4 Apr 2024 17:11:53 -0700 Subject: [PATCH 04/10] Address more feedback --- src/OpenApi/perf/TransformersBenchmark.cs | 6 ++-- src/OpenApi/sample/Program.cs | 1 + .../AddBearerSecuritySchemeTransformer.cs | 34 ++++++++++--------- .../Transformers/AddContactTransformer.cs | 2 ++ .../Transformers/OperationTransformers.cs | 2 ++ .../src/Services/OpenApiDocumentService.cs | 24 +++++++++---- src/OpenApi/src/Services/OpenApiOptions.cs | 2 +- .../DelegateOpenApiDocumentTransformer.cs | 4 +-- ...=> TypeBasedOpenApiDocumentTransformer.cs} | 5 +-- .../test/Transformers/OpenApiOptionsTests.cs | 16 ++++----- .../Transformers/OperationTransformerTests.cs | 2 +- 11 files changed, 59 insertions(+), 39 deletions(-) rename src/OpenApi/src/Transformers/{ActivatedOpenApiDocumentTransformer.cs => TypeBasedOpenApiDocumentTransformer.cs} (70%) diff --git a/src/OpenApi/perf/TransformersBenchmark.cs b/src/OpenApi/perf/TransformersBenchmark.cs index 42af340a23fe..d98df3fce645 100644 --- a/src/OpenApi/perf/TransformersBenchmark.cs +++ b/src/OpenApi/perf/TransformersBenchmark.cs @@ -71,7 +71,7 @@ public void DocumentTransformerAsDelegate_Delegate() [Benchmark] public async Task OperationTransformerAsDelegate() { - for (var i = 0; i < InvocationCount; i++) + for (var i = 0; i <= InvocationCount; i++) { await _documentService.GetOpenApiDocumentAsync(); } @@ -80,7 +80,7 @@ public async Task OperationTransformerAsDelegate() [Benchmark] public async Task ActivatedDocumentTransformer() { - for (var i = 0; i < InvocationCount; i++) + for (var i = 0; i <= InvocationCount; i++) { await _documentService.GetOpenApiDocumentAsync(); } @@ -89,7 +89,7 @@ public async Task ActivatedDocumentTransformer() [Benchmark] public async Task DocumentTransformerAsDelegate() { - for (var i = 0; i < InvocationCount; i++) + for (var i = 0; i <= InvocationCount; i++) { await _documentService.GetOpenApiDocumentAsync(); } diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index e21f0091e2f4..8176afc6fccb 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.OpenApi.Models; +using Sample.Transformers; var builder = WebApplication.CreateBuilder(args); diff --git a/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs index 4ec5649790cf..02bd033b7628 100644 --- a/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs +++ b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs @@ -5,25 +5,27 @@ 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 = (authenticationSchemeProvider is not null) - ? await authenticationSchemeProvider.GetAllSchemesAsync() - : []; - var requirements = authenticationSchemes - .Where(authScheme => authScheme.Name == "Bearer") - .ToDictionary( - (authScheme) => authScheme.Name, - (authScheme) => 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; + 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 index 4713d19f9749..5d9d35f1c7ad 100644 --- a/src/OpenApi/sample/Transformers/AddContactTransformer.cs +++ b/src/OpenApi/sample/Transformers/AddContactTransformer.cs @@ -4,6 +4,8 @@ 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) diff --git a/src/OpenApi/sample/Transformers/OperationTransformers.cs b/src/OpenApi/sample/Transformers/OperationTransformers.cs index b414354aec48..71b2c9842951 100644 --- a/src/OpenApi/sample/Transformers/OperationTransformers.cs +++ b/src/OpenApi/sample/Transformers/OperationTransformers.cs @@ -6,6 +6,8 @@ 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) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index b69c6c4bde1d..da15f656c374 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -27,9 +28,20 @@ internal sealed class OpenApiDocumentService( /// 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 description, and their respective transformer contexts. + /// operations, API descriptions, and their respective transformer contexts. /// - internal ConcurrentDictionary OperationTransformerContextCache = new(); + private readonly ConcurrentDictionary _operationTransformerContextCache = new(); + + internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) + { + context = null; + if (_operationTransformerContextCache.TryGetValue(descriptionId, out var cachedContext)) + { + context = cachedContext; + return true; + } + return false; + } public async Task GetOpenApiDocumentAsync(CancellationToken cancellationToken = default) { @@ -42,11 +54,11 @@ public async Task GetOpenApiDocumentAsync(CancellationToken can Paths = GetOpenApiPaths(capturedTags), Tags = [.. capturedTags] }; - await ApplyTransformers(document, cancellationToken); + await ApplyTransformersAsync(document, cancellationToken); return document; } - private async Task ApplyTransformers(OpenApiDocument document, CancellationToken cancellationToken = default) + private async Task ApplyTransformersAsync(OpenApiDocument document, CancellationToken cancellationToken) { var documentTransformerContext = new OpenApiDocumentTransformerContext { @@ -60,7 +72,7 @@ private async Task ApplyTransformers(OpenApiDocument document, CancellationToken // Delayed initialization for DI-activated transformers // until the first time they are used when we have access // to the target service provider. - if (transformer is ActivatedOpenApiDocumentTransformer activatedTransformer) + if (transformer is TypeBasedOpenApiDocumentTransformer activatedTransformer) { activatedTransformer.Initialize(serviceProvider); await activatedTransformer.TransformAsync(document, documentTransformerContext, cancellationToken); @@ -113,7 +125,7 @@ private Dictionary GetOperations(IGrouping() where TTransformerType : IOpenApiDocumentTransformer { - DocumentTransformers.Add(new ActivatedOpenApiDocumentTransformer(typeof(TTransformerType))); + DocumentTransformers.Add(new TypeBasedOpenApiDocumentTransformer(typeof(TTransformerType))); return this; } diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs index 6c717cb7485e..4e83e39a13c9 100644 --- a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -38,7 +38,7 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf { if (operation.Extensions.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionIdExtension) && descriptionIdExtension is OpenApiString { Value: var descriptionId } && - documentService.OperationTransformerContextCache.TryGetValue(descriptionId, out var operationContext)) + documentService.TryGetCachedOperationTransformerContext(descriptionId, out var operationContext)) { await _operationTransformer(operation, operationContext, cancellationToken); } @@ -50,7 +50,7 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf // 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."); + 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/ActivatedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs similarity index 70% rename from src/OpenApi/src/Transformers/ActivatedOpenApiDocumentTransformer.cs rename to src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs index 92de5aa44c85..439d13caec93 100644 --- a/src/OpenApi/src/Transformers/ActivatedOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs @@ -7,13 +7,14 @@ namespace Microsoft.AspNetCore.OpenApi; -internal sealed class ActivatedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer +internal sealed class TypeBasedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer { internal IOpenApiDocumentTransformer? Transformer { get; set; } internal void Initialize(IServiceProvider serviceProvider) { - Transformer ??= ActivatorUtilities.CreateInstance(serviceProvider, transformerType) as IOpenApiDocumentTransformer; + Debug.Assert(typeof(IOpenApiDocumentTransformer).IsAssignableFrom(transformerType), "Type should implement IOpenApiDocumentTransformer."); + Transformer ??= (IOpenApiDocumentTransformer)ActivatorUtilities.CreateInstance(serviceProvider, transformerType); } public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) diff --git a/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs index 2ccb5a27bbb7..dba656f4cf30 100644 --- a/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs +++ b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs @@ -21,8 +21,8 @@ public void UseTransformer_WithDocumentTransformerDelegate() var result = options.UseTransformer(transformer); // Assert - Assert.Single(options.DocumentTransformers); - Assert.IsType(options.DocumentTransformers[0]); + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); Assert.IsType(result); } @@ -37,8 +37,8 @@ public void UseTransformer_WithDocumentTransformerInstance() var result = options.UseTransformer(transformer); // Assert - Assert.Single(options.DocumentTransformers); - Assert.Same(transformer, options.DocumentTransformers[0]); + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.Same(transformer, insertedTransformer); Assert.IsType(result); } @@ -52,8 +52,8 @@ public void UseTransformer_WithDocumentTransformerType() var result = options.UseTransformer(); // Assert - Assert.Single(options.DocumentTransformers); - Assert.IsType(options.DocumentTransformers[0]); + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); Assert.IsType(result); } @@ -72,8 +72,8 @@ public void UseTransformer_WithOperationTransformerDelegate() var result = options.UseOperationTransformer(transformer); // Assert - Assert.Single(options.DocumentTransformers); - Assert.IsType(options.DocumentTransformers[0]); + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); Assert.IsType(result); } diff --git a/src/OpenApi/test/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Transformers/OperationTransformerTests.cs index 4bf00d10b67c..877d2dfd52ba 100644 --- a/src/OpenApi/test/Transformers/OperationTransformerTests.cs +++ b/src/OpenApi/test/Transformers/OperationTransformerTests.cs @@ -143,6 +143,6 @@ public async Task OperationTransformer_ThrowsExceptionIfDescriptionIdNotFound() }); var exception = await Assert.ThrowsAsync(() => VerifyOpenApiDocument(builder, options, _ => { })); - Assert.Equal("Cached operation transformer context not found.", exception.Message); + Assert.Equal("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute.", exception.Message); } } From 73fc68a58d1a8128004208eb86b9c79cc2b0d704 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 5 Apr 2024 14:05:57 -0700 Subject: [PATCH 05/10] Handle service lifetimes correctly in activated transformers --- .../src/Services/OpenApiDocumentService.cs | 14 +--- .../TypeBasedOpenApiDocumentTransformer.cs | 14 +--- .../OpenApiDocumentServiceTestsBase.cs | 26 ++++--- .../Transformers/DocumentTransformerTests.cs | 76 +++++++++++++++++++ 4 files changed, 99 insertions(+), 31 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index da15f656c374..ff15f51df5ff 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -66,21 +66,11 @@ private async Task ApplyTransformersAsync(OpenApiDocument document, Cancellation 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]; - // Delayed initialization for DI-activated transformers - // until the first time they are used when we have access - // to the target service provider. - if (transformer is TypeBasedOpenApiDocumentTransformer activatedTransformer) - { - activatedTransformer.Initialize(serviceProvider); - await activatedTransformer.TransformAsync(document, documentTransformerContext, cancellationToken); - } - else - { - await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); - } + await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); } } diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs index 439d13caec93..0622b4349979 100644 --- a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs @@ -9,17 +9,11 @@ namespace Microsoft.AspNetCore.OpenApi; internal sealed class TypeBasedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer { - internal IOpenApiDocumentTransformer? Transformer { get; set; } - - internal void Initialize(IServiceProvider serviceProvider) - { - Debug.Assert(typeof(IOpenApiDocumentTransformer).IsAssignableFrom(transformerType), "Type should implement IOpenApiDocumentTransformer."); - Transformer ??= (IOpenApiDocumentTransformer)ActivatorUtilities.CreateInstance(serviceProvider, transformerType); - } - + private readonly ObjectFactory _transformerFactory = ActivatorUtilities.CreateFactory(transformerType, []); public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { - Debug.Assert(Transformer != null, "Transformer should have been initialized."); - return Transformer.TransformAsync(document, context, cancellationToken); + var transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiDocumentTransformer; + Debug.Assert(transformer != null, $"The type {transformerType} does not implement {nameof(IOpenApiDocumentTransformer)}."); + return transformer.TransformAsync(document, context, cancellationToken); } } diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs index 31daf7c8f5da..c517dd7e9bd2 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Moq; @@ -42,9 +41,8 @@ internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuild var apiDescriptionGroupCollectionProvider = CreateApiDescriptionGroupCollectionProvider(context.Results); - var serviceProvider = new TestServiceProvider(); - var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, serviceProvider); - serviceProvider.TestDocumentService = documentService; + var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, builder.ServiceProvider); + ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; return documentService; } @@ -64,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 { @@ -87,8 +89,14 @@ public TestEndpointRouteBuilder(IApplicationBuilder applicationBuilder) 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)) @@ -96,7 +104,7 @@ public object GetKeyedService(Type serviceType, object serviceKey) return TestDocumentService; } - return null; + return _serviceProvider.GetKeyedService(serviceType, serviceKey); } public object GetRequiredKeyedService(Type serviceType, object serviceKey) @@ -106,7 +114,7 @@ public object GetRequiredKeyedService(Type serviceType, object serviceKey) return TestDocumentService; } - return null; + return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); } public object GetService(Type serviceType) @@ -116,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 index 9c9a29be2f01..7163866c2093 100644 --- a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.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.Globalization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; public class DocumentTransformerTests : OpenApiDocumentServiceTestBase @@ -68,6 +70,58 @@ await VerifyOpenApiDocument(builder, options, document => }); } + [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); + }); + } + private class ActivatedTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) @@ -76,4 +130,26 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC 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; + } } From b7ad2017e47ac75f2a69531d013596d1a400f028 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 5 Apr 2024 14:57:41 -0700 Subject: [PATCH 06/10] Handle disposable transformers --- .../src/Services/OpenApiDocumentService.cs | 20 ++- .../TypeBasedOpenApiDocumentTransformer.cs | 21 +++- .../Transformers/DocumentTransformerTests.cs | 115 ++++++++++++++++++ 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index ff15f51df5ff..3c9263a4ac6b 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -70,7 +70,25 @@ private async Task ApplyTransformersAsync(OpenApiDocument document, Cancellation for (var i = 0; i < _options.DocumentTransformers.Count; i++) { var transformer = _options.DocumentTransformers[i]; - await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); + try + { + await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); + } + finally + { + if (transformer is IDisposable disposable) + { + disposable.Dispose(); + } + if (transformer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + if (transformer is TypeBasedOpenApiDocumentTransformer typedTransformer) + { + await typedTransformer.DisposeAsync(); + } + } } } diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs index 0622b4349979..c7e7d681e19c 100644 --- a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs @@ -10,10 +10,25 @@ namespace Microsoft.AspNetCore.OpenApi; internal sealed class TypeBasedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer { private readonly ObjectFactory _transformerFactory = ActivatorUtilities.CreateFactory(transformerType, []); + private IOpenApiDocumentTransformer? _transformer; + + public ValueTask DisposeAsync() + { + if (_transformer is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + if (_transformer is IDisposable disposable) + { + disposable.Dispose(); + } + return ValueTask.CompletedTask; + } + public 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)}."); - return transformer.TransformAsync(document, context, cancellationToken); + _transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiDocumentTransformer; + Debug.Assert(_transformer != null, $"The type {transformerType} does not implement {nameof(IOpenApiDocumentTransformer)}."); + return _transformer.TransformAsync(document, context, cancellationToken); } } diff --git a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs index 7163866c2093..573bc2b29b9e 100644 --- a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs @@ -122,6 +122,84 @@ await VerifyOpenApiDocument(builder, options, document => }); } + [Fact] + public async Task DocumentTransformer_SupportsDisposableInstanceTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + var transformer = new DisposableTransformer(); + options.UseTransformer(transformer); + + Assert.False(transformer.Disposed); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + Assert.True(transformer.Disposed); + } + + [Fact] + public async Task DocumentTransformer_SupportsAsyncDisposableInstanceTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + var transformer = new AsyncDisposableTransformer(); + options.UseTransformer(transformer); + + Assert.False(transformer.Disposed); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + Assert.True(transformer.Disposed); + } + + [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) @@ -131,6 +209,43 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC } } + 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) From 604498c693d199103e53e1036682507ee0078b79 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 5 Apr 2024 15:04:48 -0700 Subject: [PATCH 07/10] Benchmark on transformer count --- src/OpenApi/perf/TransformersBenchmark.cs | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/OpenApi/perf/TransformersBenchmark.cs b/src/OpenApi/perf/TransformersBenchmark.cs index d98df3fce645..2265691ec087 100644 --- a/src/OpenApi/perf/TransformersBenchmark.cs +++ b/src/OpenApi/perf/TransformersBenchmark.cs @@ -21,7 +21,7 @@ namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; public class TransformersBenchmark : OpenApiDocumentServiceTestBase { [Params(10, 100, 1000)] - public int InvocationCount { get; set; } + public int TransformerCount { get; set; } private readonly IEndpointRouteBuilder _builder = CreateBuilder(); private readonly OpenApiOptions _options = new OpenApiOptions(); @@ -31,7 +31,7 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase public void OperationTransformerAsDelegate_Setup() { _builder.MapGet("/", () => { }); - for (var i = 0; i <= 1000; i++) + for (var i = 0; i <= TransformerCount; i++) { _options.UseOperationTransformer((operation, context, token) => { @@ -46,7 +46,7 @@ public void OperationTransformerAsDelegate_Setup() public void ActivatedDocumentTransformer_Setup() { _builder.MapGet("/", () => { }); - for (var i = 0; i <= 1000; i++) + for (var i = 0; i <= TransformerCount; i++) { _options.UseTransformer(); } @@ -57,7 +57,7 @@ public void ActivatedDocumentTransformer_Setup() public void DocumentTransformerAsDelegate_Delegate() { _builder.MapGet("/", () => { }); - for (var i = 0; i <= 1000; i++) + for (var i = 0; i <= TransformerCount; i++) { _options.UseTransformer((document, context, token) => { @@ -71,28 +71,19 @@ public void DocumentTransformerAsDelegate_Delegate() [Benchmark] public async Task OperationTransformerAsDelegate() { - for (var i = 0; i <= InvocationCount; i++) - { - await _documentService.GetOpenApiDocumentAsync(); - } + await _documentService.GetOpenApiDocumentAsync(); } [Benchmark] public async Task ActivatedDocumentTransformer() { - for (var i = 0; i <= InvocationCount; i++) - { - await _documentService.GetOpenApiDocumentAsync(); - } + await _documentService.GetOpenApiDocumentAsync(); } [Benchmark] public async Task DocumentTransformerAsDelegate() { - for (var i = 0; i <= InvocationCount; i++) - { - await _documentService.GetOpenApiDocumentAsync(); - } + await _documentService.GetOpenApiDocumentAsync(); } private class ActivatedTransformer : IOpenApiDocumentTransformer From 40a9912db7348010e67c2e20f0185dff8b4972cf Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 5 Apr 2024 15:48:24 -0700 Subject: [PATCH 08/10] Fix up disposable code and improve paths perf --- .../src/Services/OpenApiDocumentService.cs | 20 +--------- .../DelegateOpenApiDocumentTransformer.cs | 22 +++++++++- .../TypeBasedOpenApiDocumentTransformer.cs | 28 ++++++------- .../Transformers/DocumentTransformerTests.cs | 40 ------------------- 4 files changed, 36 insertions(+), 74 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 3c9263a4ac6b..ff15f51df5ff 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -70,25 +70,7 @@ private async Task ApplyTransformersAsync(OpenApiDocument document, Cancellation for (var i = 0; i < _options.DocumentTransformers.Count; i++) { var transformer = _options.DocumentTransformers[i]; - try - { - await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); - } - finally - { - if (transformer is IDisposable disposable) - { - disposable.Dispose(); - } - if (transformer is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - if (transformer is TypeBasedOpenApiDocumentTransformer typedTransformer) - { - await typedTransformer.DisposeAsync(); - } - } + await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); } } diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs index 4e83e39a13c9..63db26908d40 100644 --- a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -9,6 +9,20 @@ 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; @@ -34,8 +48,14 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf var documentService = context.ApplicationServices.GetRequiredKeyedService(context.DocumentName); foreach (var pathItem in document.Paths.Values) { - foreach (var operation in pathItem.Operations.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)) diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs index c7e7d681e19c..4883b38bce76 100644 --- a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs @@ -10,25 +10,25 @@ namespace Microsoft.AspNetCore.OpenApi; internal sealed class TypeBasedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer { private readonly ObjectFactory _transformerFactory = ActivatorUtilities.CreateFactory(transformerType, []); - private IOpenApiDocumentTransformer? _transformer; - public ValueTask DisposeAsync() + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { - if (_transformer is IAsyncDisposable asyncDisposable) + var transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiDocumentTransformer; + Debug.Assert(transformer != null, $"The type {transformerType} does not implement {nameof(IOpenApiDocumentTransformer)}."); + try { - return asyncDisposable.DisposeAsync(); + await transformer.TransformAsync(document, context, cancellationToken); } - if (_transformer is IDisposable disposable) + finally { - disposable.Dispose(); + if (transformer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (transformer is IDisposable disposable) + { + disposable.Dispose(); + } } - return ValueTask.CompletedTask; - } - - public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) - { - _transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiDocumentTransformer; - Debug.Assert(_transformer != null, $"The type {transformerType} does not implement {nameof(IOpenApiDocumentTransformer)}."); - return _transformer.TransformAsync(document, context, cancellationToken); } } diff --git a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs index 573bc2b29b9e..2af401a19b0b 100644 --- a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs @@ -122,46 +122,6 @@ await VerifyOpenApiDocument(builder, options, document => }); } - [Fact] - public async Task DocumentTransformer_SupportsDisposableInstanceTransformer() - { - var builder = CreateBuilder(); - - builder.MapGet("/todo", () => { }); - builder.MapGet("/user", () => { }); - - var options = new OpenApiOptions(); - var transformer = new DisposableTransformer(); - options.UseTransformer(transformer); - - Assert.False(transformer.Disposed); - await VerifyOpenApiDocument(builder, options, document => - { - Assert.Equal("Info Description", document.Info.Description); - }); - Assert.True(transformer.Disposed); - } - - [Fact] - public async Task DocumentTransformer_SupportsAsyncDisposableInstanceTransformer() - { - var builder = CreateBuilder(); - - builder.MapGet("/todo", () => { }); - builder.MapGet("/user", () => { }); - - var options = new OpenApiOptions(); - var transformer = new AsyncDisposableTransformer(); - options.UseTransformer(transformer); - - Assert.False(transformer.Disposed); - await VerifyOpenApiDocument(builder, options, document => - { - Assert.Equal("Info Description", document.Info.Description); - }); - Assert.True(transformer.Disposed); - } - [Fact] public async Task DocumentTransformer_SupportsDisposableActivatedTransformer() { From a76b0375be40953cd79cbced788bb40279772717 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 5 Apr 2024 16:34:39 -0700 Subject: [PATCH 09/10] Remove unused imports in SharedTypes for RDG tests --- .../test/RequestDelegateGenerator/SharedTypes.cs | 2 -- 1 file changed, 2 deletions(-) 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; From 578af9f24bf7f9cd374eff499e655fd0b36cffa0 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 8 Apr 2024 11:05:01 -0700 Subject: [PATCH 10/10] Clean up benchmarks and TryGetCachedOperationTransformerContext --- eng/Versions.props | 2 +- src/OpenApi/perf/TransformersBenchmark.cs | 4 ---- src/OpenApi/src/Services/OpenApiDocumentService.cs | 10 +--------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 6967ad8f3905..e8a5d41ad29e 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -290,7 +290,7 @@ 1.10.3 0.9.9 - 0.13.12 + 0.13.0 4.2.1 2.3.0 6.0.0 diff --git a/src/OpenApi/perf/TransformersBenchmark.cs b/src/OpenApi/perf/TransformersBenchmark.cs index 2265691ec087..4dd02d9989d0 100644 --- a/src/OpenApi/perf/TransformersBenchmark.cs +++ b/src/OpenApi/perf/TransformersBenchmark.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection.Metadata; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.OpenApi.Models; @@ -16,8 +14,6 @@ namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; /// measure the impact of (a) context-object creation and caching and (b) /// enumerator usage when processing operations in a given document. /// -[MemoryDiagnoser] -[EventPipeProfiler(EventPipeProfile.GcVerbose)] public class TransformersBenchmark : OpenApiDocumentServiceTestBase { [Params(10, 100, 1000)] diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index ff15f51df5ff..aa8bd0509c3f 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -33,15 +33,7 @@ internal sealed class OpenApiDocumentService( private readonly ConcurrentDictionary _operationTransformerContextCache = new(); internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) - { - context = null; - if (_operationTransformerContextCache.TryGetValue(descriptionId, out var cachedContext)) - { - context = cachedContext; - return true; - } - return false; - } + => _operationTransformerContextCache.TryGetValue(descriptionId, out context); public async Task GetOpenApiDocumentAsync(CancellationToken cancellationToken = default) {