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