diff --git a/AspNetCore.sln b/AspNetCore.sln
index 8547083d378e..296a8be79925 100644
--- a/AspNetCore.sln
+++ b/AspNetCore.sln
@@ -1788,12 +1788,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NotReferencedInWasmCodePack
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.WasmRemoteAuthentication", "src\Components\test\testassets\Components.WasmRemoteAuthentication\Components.WasmRemoteAuthentication.csproj", "{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "src\OpenApi\sample\Sample.csproj", "{6DEC24A8-A166-432F-8E3B-58FFCDA92F52}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hybrid", "Hybrid", "{2D64CA23-6E81-488E-A7D3-9BDF87240098}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid", "src\Caching\Hybrid\src\Microsoft.Extensions.Caching.Hybrid.csproj", "{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid.Tests", "src\Caching\Hybrid\test\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "{CF63C942-895A-4F6B-888A-7653D7C4991A}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{9DC6B242-457B-4767-A84B-C3D23B76C642}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Microbenchmarks", "src\OpenApi\perf\Microbenchmarks\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", "{D53F0EF7-0CDC-49B4-AA2D-229901B0A734}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -10795,6 +10801,22 @@ Global
{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x64.Build.0 = Release|Any CPU
{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.ActiveCfg = Release|Any CPU
{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.Build.0 = Release|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|arm64.ActiveCfg = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|arm64.Build.0 = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x64.Build.0 = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x86.Build.0 = Debug|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|arm64.ActiveCfg = Release|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|arm64.Build.0 = Release|Any CPU
+ {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x64.ActiveCfg = Release|Any CPU
+ {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
{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|arm64.ActiveCfg = Debug|Any CPU
@@ -10827,6 +10849,22 @@ Global
{CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.Build.0 = Release|Any CPU
{CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.ActiveCfg = Release|Any CPU
{CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.Build.0 = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|arm64.ActiveCfg = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|arm64.Build.0 = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x64.Build.0 = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x86.Build.0 = Debug|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|arm64.ActiveCfg = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|arm64.Build.0 = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x64.ActiveCfg = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x64.Build.0 = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x86.ActiveCfg = Release|Any CPU
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -11710,9 +11748,12 @@ Global
{15D08EA7-8C63-45FB-8B4D-C5F8E43B433E} = {05A169C7-4F20-4516-B10A-B13C5649D346}
{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}
{2D64CA23-6E81-488E-A7D3-9BDF87240098} = {0F39820F-F4A5-41C6-9809-D79B68F032EF}
{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9} = {2D64CA23-6E81-488E-A7D3-9BDF87240098}
{CF63C942-895A-4F6B-888A-7653D7C4991A} = {2D64CA23-6E81-488E-A7D3-9BDF87240098}
+ {9DC6B242-457B-4767-A84B-C3D23B76C642} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B}
+ {D53F0EF7-0CDC-49B4-AA2D-229901B0A734} = {9DC6B242-457B-4767-A84B-C3D23B76C642}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index a987f75730aa..2539beee1c9b 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -69,6 +69,7 @@ and are generated based on the last package release.
+
diff --git a/eng/Versions.props b/eng/Versions.props
index 6a93f118294f..ca06da0a1f0b 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -335,6 +335,7 @@
4.0.5
6.0.0-preview.3.21167.1
1.6.13
+ 1.6.13
6.0.322601
1.10.93
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/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
index e32c4ee748ee..075d7b90a560 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
+++ b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
@@ -20,5 +20,6 @@
+
diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf
index 0311c6b7ddcd..1f85792cc331 100644
--- a/src/OpenApi/OpenApi.slnf
+++ b/src/OpenApi/OpenApi.slnf
@@ -8,7 +8,9 @@
"src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj",
"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\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj",
+ "src\\OpenApi\\sample\\Sample.csproj",
+ "src\\OpenApi\\perf\\Microbenchmarks\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj"
]
}
-}
\ No newline at end of file
+}
diff --git a/src/OpenApi/perf/Microbenchmarks/AssemblyInfo.cs b/src/OpenApi/perf/Microbenchmarks/AssemblyInfo.cs
new file mode 100644
index 000000000000..09f49228e9e6
--- /dev/null
+++ b/src/OpenApi/perf/Microbenchmarks/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/Microbenchmarks/GenerationBenchmarks.cs b/src/OpenApi/perf/Microbenchmarks/GenerationBenchmarks.cs
new file mode 100644
index 000000000000..1c676b4545f8
--- /dev/null
+++ b/src/OpenApi/perf/Microbenchmarks/GenerationBenchmarks.cs
@@ -0,0 +1,44 @@
+// 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.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks;
+
+///
+/// The following benchmarks are used to assess the performance of the
+/// core OpenAPI document generation logic. The parameter under test here
+/// is the number of endpoints/operations that are defined in the application.
+///
+[MemoryDiagnoser]
+public class GenerationBenchmarks : OpenApiDocumentServiceTestBase
+{
+ [Params(10, 100, 1000)]
+ public int EndpointCount { get; set; }
+
+ private readonly IEndpointRouteBuilder _builder = CreateBuilder();
+ private readonly OpenApiOptions _options = new OpenApiOptions();
+ private OpenApiDocumentService _documentService;
+
+ [GlobalSetup(Target = nameof(GenerateDocument))]
+ public void OperationTransformerAsDelegate_Setup()
+ {
+ _builder.MapGet("/", () => { });
+ for (var i = 0; i <= EndpointCount; i++)
+ {
+ _builder.MapGet($"/{i}", (int i) => new Todo(1, "Write benchmarks", false, DateTime.Now));
+ _builder.MapPost($"/{i}", (Todo todo) => Results.Ok());
+ _builder.MapDelete($"/{i}", (string id) => Results.NoContent());
+ }
+ _documentService = CreateDocumentService(_builder, _options);
+ }
+
+ [Benchmark]
+ public async Task GenerateDocument()
+ {
+ await _documentService.GetOpenApiDocumentAsync();
+ }
+}
diff --git a/src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj b/src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj
new file mode 100644
index 000000000000..df6d36dd7dca
--- /dev/null
+++ b/src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj
@@ -0,0 +1,23 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+ Exe
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs
new file mode 100644
index 000000000000..4dd02d9989d0
--- /dev/null
+++ b/src/OpenApi/perf/Microbenchmarks/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/EndpointRouteBuilderExtensions.cs b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs
new file mode 100644
index 000000000000..fd196d7fc101
--- /dev/null
+++ b/src/OpenApi/sample/EndpointRouteBuilderExtensions.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.
+
+internal static class OpenApiEndpointRouteBuilderExtensions
+{
+ ///
+ /// Helper method to render Swagger UI view for testing.
+ ///
+ public static IEndpointConventionBuilder MapSwaggerUi(this IEndpointRouteBuilder endpoints)
+ {
+ return endpoints.MapGet("/swagger/{documentName}", (string documentName) => Results.Content($$"""
+
+
+
+ Codestin Search App
+
+
+
+
+
+
+
+
+
+
+
+ """, "text/html")).ExcludeFromDescription();
+ }
+}
diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs
new file mode 100644
index 000000000000..0ce2d85244ec
--- /dev/null
+++ b/src/OpenApi/sample/Program.cs
@@ -0,0 +1,70 @@
+// 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;
+using Microsoft.OpenApi.Models;
+using Sample.Transformers;
+
+var builder = WebApplication.CreateBuilder(args);
+
+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;
+ });
+});
+builder.Services.AddOpenApi("responses");
+builder.Services.AddOpenApi("forms");
+
+var app = builder.Build();
+
+app.MapOpenApi();
+if (app.Environment.IsDevelopment())
+{
+ app.MapSwaggerUi();
+}
+
+var forms = app.MapGroup("forms")
+ .WithGroupName("forms");
+
+if (app.Environment.IsDevelopment())
+{
+ forms.DisableAntiforgery();
+}
+
+forms.MapPost("/form-file", (IFormFile resume) => Results.Ok(resume.FileName));
+forms.MapPost("/form-files", (IFormFileCollection files) => Results.Ok(files.Count));
+forms.MapPost("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo));
+
+var v1 = app.MapGroup("v1")
+ .WithGroupName("v1");
+var v2 = app.MapGroup("v2")
+ .WithGroupName("v2");
+var responses = app.MapGroup("responses")
+ .WithGroupName("responses");
+
+v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo))
+ .WithSummary("Creates a new todo item.");
+v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
+ .WithDescription("Returns a specific todo item.");
+
+v2.MapGet("/users", () => new [] { "alice", "bob" })
+ .WithTags("users");
+
+v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" }));
+
+responses.MapGet("/200-add-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
+ .Produces(additionalContentTypes: "text/xml");
+
+responses.MapGet("/200-only-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
+ .Produces(contentType: "text/xml");
+
+app.Run();
diff --git a/src/OpenApi/sample/Properties/launchSettings.json b/src/OpenApi/sample/Properties/launchSettings.json
new file mode 100644
index 000000000000..e7c91524954d
--- /dev/null
+++ b/src/OpenApi/sample/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:43164",
+ "sslPort": 44391
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5051",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7174;http://localhost:5051",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/OpenApi/sample/Sample.csproj b/src/OpenApi/sample/Sample.csproj
new file mode 100644
index 000000000000..882dbbed211d
--- /dev/null
+++ b/src/OpenApi/sample/Sample.csproj
@@ -0,0 +1,25 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/sample/appsettings.Development.json b/src/OpenApi/sample/appsettings.Development.json
new file mode 100644
index 000000000000..0c208ae9181e
--- /dev/null
+++ b/src/OpenApi/sample/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/OpenApi/sample/appsettings.json b/src/OpenApi/sample/appsettings.json
new file mode 100644
index 000000000000..10f68b8c8b4f
--- /dev/null
+++ b/src/OpenApi/sample/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs
new file mode 100644
index 000000000000..9e134604641a
--- /dev/null
+++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs
@@ -0,0 +1,116 @@
+// 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 System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.OpenApi.Models;
+
+internal static class ApiDescriptionExtensions
+{
+ ///
+ /// Maps the HTTP method of the ApiDescription to the OpenAPI .
+ ///
+ /// The ApiDescription to resolve an operation type from.
+ /// The associated with the given .
+ public static OperationType GetOperationType(this ApiDescription apiDescription) =>
+ apiDescription.HttpMethod?.ToUpperInvariant() switch
+ {
+ "GET" => OperationType.Get,
+ "POST" => OperationType.Post,
+ "PUT" => OperationType.Put,
+ "DELETE" => OperationType.Delete,
+ "PATCH" => OperationType.Patch,
+ "HEAD" => OperationType.Head,
+ "OPTIONS" => OperationType.Options,
+ "TRACE" => OperationType.Trace,
+ _ => throw new InvalidOperationException($"Unsupported HTTP method: {apiDescription.HttpMethod}"),
+ };
+
+ ///
+ /// Maps the relative path included in the ApiDescription to the path
+ /// that should be included in the OpenApiDocument. This typically
+ /// consists of removing any constraints from route parameter parts
+ /// and retaining only the literals.
+ ///
+ /// The ApiDescription to resolve an item path from.
+ /// The resolved item path for the given .
+ public static string MapRelativePathToItemPath(this ApiDescription apiDescription)
+ {
+ Debug.Assert(apiDescription.RelativePath != null, "Relative path cannot be null.");
+ // "" -> "/"
+ if (string.IsNullOrEmpty(apiDescription.RelativePath))
+ {
+ return "/";
+ }
+ var strippedRoute = new StringBuilder();
+ var routePattern = RoutePatternFactory.Parse(apiDescription.RelativePath);
+ for (var i = 0; i < routePattern.PathSegments.Count; i++)
+ {
+ strippedRoute.Append('/');
+ var segment = routePattern.PathSegments[i];
+ foreach (var part in segment.Parts)
+ {
+ if (part is RoutePatternLiteralPart literalPart)
+ {
+ strippedRoute.Append(literalPart.Content);
+ }
+ else if (part is RoutePatternParameterPart parameterPart)
+ {
+ strippedRoute.Append('{');
+ strippedRoute.Append(parameterPart.Name);
+ strippedRoute.Append('}');
+ }
+ else if (part is RoutePatternSeparatorPart separatorPart)
+ {
+ strippedRoute.Append(separatorPart.Content);
+ }
+ }
+ }
+ return strippedRoute.ToString();
+ }
+
+ ///
+ /// Determines if the given is a request body parameter.
+ ///
+ /// The to check.
+ /// Returns if the given parameter comes from the request body, otherwise.
+ public static bool IsRequestBodyParameter(this ApiParameterDescription apiParameterDescription) =>
+ apiParameterDescription.Source == BindingSource.Body ||
+ apiParameterDescription.Source == BindingSource.FormFile ||
+ apiParameterDescription.Source == BindingSource.Form;
+
+ ///
+ /// Retrieves the form parameters from the ApiDescription, if they exist.
+ ///
+ /// The ApiDescription to resolve form parameters from.
+ /// A list of associated with the form parameters.
+ /// if form parameters were found, otherwise.
+ public static bool TryGetFormParameters(this ApiDescription apiDescription, out IEnumerable formParameters)
+ {
+ formParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Form || parameter.Source == BindingSource.FormFile);
+ return formParameters.Any();
+ }
+
+ ///
+ /// Retrieves the body parameter from the ApiDescription, if it exists.
+ ///
+ /// The ApiDescription to resolve the body parameter from.
+ /// The associated with the body parameter.
+ /// if a single body parameter was found, otherwise.
+ public static bool TryGetBodyParameter(this ApiDescription apiDescription, [NotNullWhen(true)] out ApiParameterDescription? bodyParameter)
+ {
+ bodyParameter = null;
+ var bodyParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Body);
+ if (bodyParameters.Count() == 1)
+ {
+ bodyParameter = bodyParameters.Single();
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs
similarity index 100%
rename from src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs
rename to src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs
diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs
new file mode 100644
index 000000000000..7bae09542251
--- /dev/null
+++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs
@@ -0,0 +1,65 @@
+// 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.Http;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Extensions;
+using Microsoft.OpenApi.Writers;
+
+namespace Microsoft.AspNetCore.Builder;
+
+///
+/// OpenAPI-related methods for .
+///
+public static class OpenApiEndpointRouteBuilderExtensions
+{
+ ///
+ /// Register an endpoint onto the current application for resolving the OpenAPI document associated
+ /// with the current application.
+ ///
+ /// The .
+ /// The route to register the endpoint on. Must include the 'documentName' route parameter.
+ /// An that can be used to further customize the endpoint.
+ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = OpenApiConstants.DefaultOpenApiRoute)
+ {
+ var options = endpoints.ServiceProvider.GetRequiredService>();
+ return endpoints.MapGet(pattern, async (HttpContext context, string documentName = OpenApiConstants.DefaultDocumentName) =>
+ {
+ // It would be ideal to use the `HttpResponseStreamWriter` to
+ // asynchronously write to the response stream here but Microsoft.OpenApi
+ // does not yet support async APIs on their writers.
+ // See https://github.com/microsoft/OpenAPI.NET/issues/421 for more info.
+ var documentService = context.RequestServices.GetKeyedService(documentName);
+ if (documentService is null)
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ context.Response.ContentType = "text/plain;charset=utf-8";
+ await context.Response.WriteAsync($"No OpenAPI document with the name '{documentName}' was found.");
+ }
+ else
+ {
+ var document = await documentService.GetOpenApiDocumentAsync(context.RequestAborted);
+ var documentOptions = options.Get(documentName);
+ using var output = MemoryBufferWriter.Get();
+ using var writer = Utf8BufferTextWriter.Get(output);
+ try
+ {
+ document.Serialize(new OpenApiJsonWriter(writer), documentOptions.OpenApiVersion);
+ await context.Response.BodyWriter.WriteAsync(output.ToArray());
+ await context.Response.BodyWriter.FlushAsync();
+ }
+ finally
+ {
+ MemoryBufferWriter.Return(output);
+ Utf8BufferTextWriter.Return(writer);
+ }
+
+ }
+ }).ExcludeFromDescription();
+ }
+}
diff --git a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs
new file mode 100644
index 000000000000..b7372551a0ac
--- /dev/null
+++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs
@@ -0,0 +1,72 @@
+// 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.Extensions.ApiDescriptions;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// OpenAPI-related methods for .
+///
+public static class OpenApiServiceCollectionExtensions
+{
+ ///
+ /// Adds OpenAPI services related to the given document name to the specified .
+ ///
+ /// The to register services onto.
+ /// The name of the OpenAPI document associated with registered services.
+ public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ return services.AddOpenApi(documentName, _ => { });
+ }
+
+ ///
+ /// Adds OpenAPI services related to the given document name to the specified with the specified options.
+ ///
+ /// The to register services onto.
+ /// The name of the OpenAPI document associated with registered services.
+ /// A delegate used to configure the target .
+ public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action configureOptions)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configureOptions);
+
+ services.AddOpenApiCore(documentName);
+ services.Configure(documentName, options =>
+ {
+ options.DocumentName = documentName;
+ configureOptions(options);
+ });
+ return services;
+ }
+
+ ///
+ /// Adds OpenAPI services related to the default document to the specified with the specified options.
+ ///
+ /// The to register services onto.
+ /// A delegate used to configure the target .
+ public static IServiceCollection AddOpenApi(this IServiceCollection services, Action configureOptions)
+ => services.AddOpenApi(OpenApiConstants.DefaultDocumentName, configureOptions);
+
+ ///
+ /// Adds OpenAPI services related to the default document to the specified .
+ ///
+ /// The to register services onto.
+ public static IServiceCollection AddOpenApi(this IServiceCollection services)
+ => services.AddOpenApi(OpenApiConstants.DefaultDocumentName);
+
+ private static IServiceCollection AddOpenApiCore(this IServiceCollection services, string documentName)
+ {
+ services.AddEndpointsApiExplorer();
+ services.AddKeyedSingleton(documentName);
+ services.AddKeyedSingleton(documentName);
+ // Required for build-time generation
+ services.AddSingleton();
+ // Required to resolve document names for build-time generation
+ services.AddSingleton(new NamedService(documentName));
+ return services;
+ }
+}
diff --git a/src/OpenApi/src/Helpers/OpenApiTagComparer.cs b/src/OpenApi/src/Helpers/OpenApiTagComparer.cs
new file mode 100644
index 000000000000..d24d12e79768
--- /dev/null
+++ b/src/OpenApi/src/Helpers/OpenApiTagComparer.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 Microsoft.OpenApi.Models;
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+///
+/// This comparer is used to maintain a globally unique list of tags encountered
+/// in a particular OpenAPI document.
+///
+internal class OpenApiTagComparer : IEqualityComparer
+{
+ public static OpenApiTagComparer Instance { get; } = new OpenApiTagComparer();
+
+ public bool Equals(OpenApiTag? x, OpenApiTag? y)
+ {
+ if (x is null && y is null)
+ {
+ return true;
+ }
+ if (x is null || y is null)
+ {
+ return false;
+ }
+ // Tag comparisons are case-sensitive by default. Although the OpenAPI specification
+ // only outlines case sensitivity for property names, we extend this principle to
+ // property values for tag names as well.
+ // See https://spec.openapis.org/oas/v3.1.0#format.
+ return string.Equals(x.Name, y.Name, StringComparison.Ordinal);
+ }
+
+ public int GetHashCode(OpenApiTag obj) => obj.Name.GetHashCode();
+}
diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
index 34a81cb70466..39c3bd0e05ac 100644
--- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
+++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
@@ -4,14 +4,18 @@
$(DefaultNetCoreTargetFramework)
true
aspnetcore;openapi
+
+ true
Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations.
+
+
@@ -24,6 +28,12 @@
+
-
\ No newline at end of file
+
+
+
+
+
+
diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..f32c9da9334c 100644
--- a/src/OpenApi/src/PublicAPI.Unshipped.txt
+++ b/src/OpenApi/src/PublicAPI.Unshipped.txt
@@ -1 +1,37 @@
#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
+Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.get -> Microsoft.OpenApi.OpenApiSpecVersion
+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/IDocumentProvider.cs b/src/OpenApi/src/Services/IDocumentProvider.cs
new file mode 100644
index 000000000000..61ef9dc560fe
--- /dev/null
+++ b/src/OpenApi/src/Services/IDocumentProvider.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.ApiDescriptions;
+
+///
+/// Represents a provider for OpenAPI documents to support build-time generation.
+///
+///
+/// The Microsoft.Extensions.ApiDescription.Server package and associated configuration
+/// execute the `dotnet getdocument` command at build-time to support build-time
+/// generation of documents. The `getdocument` tool launches the entry point assembly
+/// and queries it for a service that implements the `IDocumentProvider` interface. For
+/// historical reasons, the `IDocumentProvider` interface isn't exposed publicly from
+/// the framework and the `getdocument` tool instead queries for it using the type name.
+/// That means the `IDocumentProvider` interface must be declared under the namespace
+/// that it expects. For more information, see https://github.com/dotnet/aspnetcore/blob/82c9b34d7206ba56ea1d641843e1f2fe6d2a0b1c/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs#L25.
+///
+internal interface IDocumentProvider
+{
+ IEnumerable GetDocumentNames();
+ Task GenerateAsync(string documentName, TextWriter writer);
+}
diff --git a/src/OpenApi/src/Services/NamedService.cs b/src/OpenApi/src/Services/NamedService.cs
new file mode 100644
index 000000000000..ca1545313a76
--- /dev/null
+++ b/src/OpenApi/src/Services/NamedService.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.
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+///
+/// Keyed services don't provide an accessible API for resolving
+/// all the service keys associated with a given type.
+/// See https:///github.com/dotnet/runtime/issues/100105 for more info.
+/// This internal class is used to track the document names that have been registered
+/// so that they can be resolved in the `IDocumentProvider` implementation.
+/// This is inspired by the implementation used in Orleans. See
+/// https:///github.com/dotnet/orleans/blob/005ab200bc91302245857cb75efaa436296a1aae/src/Orleans.Runtime/Hosting/NamedService.cs.
+///
+internal sealed class NamedService(string name)
+{
+ public string Name { get; } = name;
+}
diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs
new file mode 100644
index 000000000000..e949dc6f8236
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiComponentService.cs
@@ -0,0 +1,38 @@
+// 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 Microsoft.AspNetCore.Http;
+using Microsoft.OpenApi.Models;
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+///
+/// Supports managing elements that belong in the "components" section of
+/// an OpenAPI document. In particular, this is the API that is used to
+/// interact with the JSON schemas that are managed by a given OpenAPI document.
+///
+internal sealed class OpenApiComponentService
+{
+ private readonly ConcurrentDictionary _schemas = new()
+ {
+ // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core.
+ [typeof(IFormFile)] = new OpenApiSchema { Type = "string", Format = "binary" },
+ [typeof(IFormFileCollection)] = new OpenApiSchema
+ {
+ Type = "array",
+ Items = new OpenApiSchema { Type = "string", Format = "binary" }
+ },
+ };
+
+ internal OpenApiSchema GetOrCreateSchema(Type type)
+ {
+ return _schemas.GetOrAdd(type, _ => CreateSchema());
+ }
+
+ // TODO: Implement this method to create a schema for a given type.
+ private static OpenApiSchema CreateSchema()
+ {
+ return new OpenApiSchema { Type = "string" };
+ }
+}
diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs
new file mode 100644
index 000000000000..9ab82ba85470
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiConstants.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+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";
+ internal const string DefaultOpenApiResponseKey = "default";
+}
diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs
new file mode 100644
index 000000000000..831475f8960a
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.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.OpenApi.Writers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.Extensions.Options;
+using System.Linq;
+using Microsoft.OpenApi.Extensions;
+
+namespace Microsoft.Extensions.ApiDescriptions;
+
+internal sealed class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocumentProvider
+{
+ ///
+ /// Serializes the OpenAPI document associated with a given document name to
+ /// the provided writer.
+ ///
+ /// The name of the document to resolve.
+ /// A text writer associated with the document to write to.
+ public async Task GenerateAsync(string documentName, TextWriter writer)
+ {
+ // Microsoft.OpenAPI does not provide async APIs for writing the JSON
+ // document to a file. See https://github.com/microsoft/OpenAPI.NET/issues/421 for
+ // more info.
+ var targetDocumentService = serviceProvider.GetRequiredKeyedService(documentName);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ var document = await targetDocumentService.GetOpenApiDocumentAsync();
+ var jsonWriter = new OpenApiJsonWriter(writer);
+ document.Serialize(jsonWriter, namedOption.OpenApiVersion);
+ }
+
+ ///
+ /// Provides all document names that are currently managed in the application.
+ ///
+ public IEnumerable GetDocumentNames()
+ {
+ // Keyed services lack an API to resolve all registered keys.
+ // We use the service provider to resolve an internal type.
+ // This type tracks registered document names.
+ // See https://github.com/dotnet/runtime/issues/100105 for more info.
+ var documentServices = serviceProvider.GetServices>();
+ return documentServices.Select(docService => docService.Name);
+ }
+}
diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs
new file mode 100644
index 000000000000..d96ec88905c8
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs
@@ -0,0 +1,337 @@
+// 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.Globalization;
+using System.Linq;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Metadata;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+internal sealed class OpenApiDocumentService(
+ [ServiceKey] string documentName,
+ IApiDescriptionGroupCollectionProvider apiDescriptionGroupCollectionProvider,
+ IHostEnvironment hostEnvironment,
+ IOptionsMonitor optionsMonitor,
+ IServiceProvider serviceProvider)
+{
+ private readonly OpenApiOptions _options = optionsMonitor.Get(documentName);
+ private readonly OpenApiComponentService _componentService = serviceProvider.GetRequiredKeyedService(documentName);
+
+ private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true };
+
+ ///
+ /// 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();
+ private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK };
+
+ 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.
+ HashSet capturedTags = new(OpenApiTagComparer.Instance);
+ var document = new OpenApiDocument
+ {
+ Info = GetOpenApiInfo(),
+ Paths = GetOpenApiPaths(capturedTags),
+ Tags = [.. capturedTags]
+ };
+ 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.
+ internal OpenApiInfo GetOpenApiInfo()
+ {
+ return new OpenApiInfo
+ {
+ Title = $"{hostEnvironment.ApplicationName} | {documentName}",
+ Version = OpenApiConstants.DefaultOpenApiVersion
+ };
+ }
+
+ ///
+ /// Gets the OpenApiPaths for the document based on the ApiDescriptions.
+ ///
+ ///
+ /// At this point in the construction of the OpenAPI document, we run
+ /// each API description through the `ShouldInclude` delegate defined in
+ /// the object to support filtering each
+ /// description instance into its appropriate document.
+ ///
+ private OpenApiPaths GetOpenApiPaths(HashSet capturedTags)
+ {
+ var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items
+ .SelectMany(group => group.Items)
+ .Where(_options.ShouldInclude)
+ .GroupBy(apiDescription => apiDescription.MapRelativePathToItemPath());
+ var paths = new OpenApiPaths();
+ foreach (var descriptions in descriptionsByPath)
+ {
+ Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null.");
+ paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions, capturedTags) });
+ }
+ return paths;
+ }
+
+ private Dictionary GetOperations(IGrouping descriptions, HashSet capturedTags)
+ {
+ var operations = new Dictionary();
+ foreach (var description in descriptions)
+ {
+ 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;
+ }
+
+ private OpenApiOperation GetOperation(ApiDescription description, HashSet capturedTags)
+ {
+ var tags = GetTags(description);
+ if (tags != null)
+ {
+ foreach (var tag in tags)
+ {
+ capturedTags.Add(tag);
+ }
+ }
+ var operation = new OpenApiOperation
+ {
+ Summary = GetSummary(description),
+ Description = GetDescription(description),
+ Responses = GetResponses(description),
+ Parameters = GetParameters(description),
+ RequestBody = GetRequestBody(description),
+ Tags = tags,
+ };
+ return operation;
+ }
+
+ private static string? GetSummary(ApiDescription description)
+ => description.ActionDescriptor.EndpointMetadata.OfType().LastOrDefault()?.Summary;
+
+ private static string? GetDescription(ApiDescription description)
+ => description.ActionDescriptor.EndpointMetadata.OfType().LastOrDefault()?.Description;
+
+ private static List? GetTags(ApiDescription description)
+ {
+ var actionDescriptor = description.ActionDescriptor;
+ if (actionDescriptor.EndpointMetadata?.OfType().LastOrDefault() is { } tagsMetadata)
+ {
+ return tagsMetadata.Tags.Select(tag => new OpenApiTag { Name = tag }).ToList();
+ }
+ // If no tags are specified, use the controller name as the tag. This effectively
+ // allows us to group endpoints by the "resource" concept (e.g. users, todos, etc.)
+ return [new OpenApiTag { Name = description.ActionDescriptor.RouteValues["controller"] }];
+ }
+
+ private static OpenApiResponses GetResponses(ApiDescription description)
+ {
+ // OpenAPI requires that each operation have a response, usually a successful one.
+ // if there are no response types defined, we assume a successful 200 OK response
+ // with no content by default.
+ if (description.SupportedResponseTypes.Count == 0)
+ {
+ return new OpenApiResponses
+ {
+ ["200"] = GetResponse(description, StatusCodes.Status200OK, _defaultApiResponseType)
+ };
+ }
+
+ var responses = new OpenApiResponses();
+ foreach (var responseType in description.SupportedResponseTypes)
+ {
+ // The "default" response type is a special case in OpenAPI used to describe
+ // the response for all HTTP status codes that are not explicitly defined
+ // for a given operation. This is typically used to describe catch-all scenarios
+ // like error responses.
+ var responseKey = responseType.IsDefaultResponse
+ ? OpenApiConstants.DefaultOpenApiResponseKey
+ : responseType.StatusCode.ToString(CultureInfo.InvariantCulture);
+ responses.Add(responseKey, GetResponse(description, responseType.StatusCode, responseType));
+ }
+ return responses;
+ }
+
+ private static OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType)
+ {
+ var description = ReasonPhrases.GetReasonPhrase(statusCode);
+ var response = new OpenApiResponse
+ {
+ Description = description,
+ Content = new Dictionary()
+ };
+
+ // ApiResponseFormats aggregates information about the supported response content types
+ // from different types of Produces metadata. This is handled by ApiExplorer so looking
+ // up values in ApiResponseFormats should provide us a complete set of the information
+ // encoded in Produces metadata added via attributes or extension methods.
+ var apiResponseFormatContentTypes = apiResponseType.ApiResponseFormats
+ .Select(responseFormat => responseFormat.MediaType);
+ foreach (var contentType in apiResponseFormatContentTypes)
+ {
+ response.Content[contentType] = new OpenApiMediaType();
+ }
+
+ // MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer
+ // looks for when generating ApiResponseFormats above so we need to pull the content
+ // types defined there separately.
+ var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata
+ .OfType()
+ .SelectMany(attr => attr.ContentTypes);
+ foreach (var contentType in explicitContentTypes)
+ {
+ response.Content[contentType] = new OpenApiMediaType();
+ }
+
+ return response;
+ }
+
+ private static List? GetParameters(ApiDescription description)
+ {
+ List? parameters = null;
+ foreach (var parameter in description.ParameterDescriptions)
+ {
+ // Parameters that should be in the request body should not be
+ // populated in the parameters list.
+ if (parameter.IsRequestBodyParameter())
+ {
+ continue;
+ }
+
+ var openApiParameter = new OpenApiParameter
+ {
+ Name = parameter.Name,
+ In = parameter.Source.Id switch
+ {
+ "Query" => ParameterLocation.Query,
+ "Header" => ParameterLocation.Header,
+ "Path" => ParameterLocation.Path,
+ _ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}")
+ },
+ // Per the OpenAPI specification, parameters that are sourced from the path
+ // are always required, regardless of the requiredness status of the parameter.
+ Required = parameter.Source == BindingSource.Path || parameter.IsRequired,
+ };
+ parameters ??= [];
+ parameters.Add(openApiParameter);
+ }
+ return parameters;
+ }
+
+ private OpenApiRequestBody? GetRequestBody(ApiDescription description)
+ {
+ // Only one parameter can be bound from the body in each request.
+ if (description.TryGetBodyParameter(out var bodyParameter))
+ {
+ return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter);
+ }
+ // If there are no body parameters, check for form parameters.
+ // Note: Form parameters and body parameters cannot exist simultaneously
+ // in the same endpoint.
+ if (description.TryGetFormParameters(out var formParameters))
+ {
+ return GetFormRequestBody(description.SupportedRequestFormats, formParameters);
+ }
+ return null;
+ }
+
+ private OpenApiRequestBody GetFormRequestBody(IList supportedRequestFormats, IEnumerable formParameters)
+ {
+ if (supportedRequestFormats.Count == 0)
+ {
+ // Assume "application/x-www-form-urlencoded" as the default media type
+ // to match the default assumed in IFormFeature.
+ supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }];
+ }
+
+ var requestBody = new OpenApiRequestBody
+ {
+ Required = formParameters.Any(parameter => parameter.IsRequired),
+ Content = new Dictionary()
+ };
+
+ // Forms are represented as objects with properties for each form field.
+ var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary() };
+ foreach (var parameter in formParameters)
+ {
+ schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
+ }
+
+ foreach (var requestFormat in supportedRequestFormats)
+ {
+ var contentType = requestFormat.MediaType;
+ requestBody.Content[contentType] = new OpenApiMediaType
+ {
+ Schema = schema,
+ Encoding = new Dictionary() { [contentType] = _defaultFormEncoding }
+ };
+ }
+
+ return requestBody;
+ }
+
+ private static OpenApiRequestBody GetJsonRequestBody(IList supportedRequestFormats, ApiParameterDescription bodyParameter)
+ {
+ if (supportedRequestFormats.Count == 0)
+ {
+ supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }];
+ }
+
+ var requestBody = new OpenApiRequestBody
+ {
+ Required = bodyParameter.IsRequired,
+ Content = new Dictionary()
+ };
+
+ foreach (var requestForm in supportedRequestFormats)
+ {
+ var contentType = requestForm.MediaType;
+ requestBody.Content[contentType] = new OpenApiMediaType();
+ }
+
+ return requestBody;
+ }
+}
diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs
similarity index 100%
rename from src/OpenApi/src/OpenApiGenerator.cs
rename to src/OpenApi/src/Services/OpenApiGenerator.cs
diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs
new file mode 100644
index 000000000000..fba727315660
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiOptions.cs
@@ -0,0 +1,92 @@
+// 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;
+
+///
+/// Options to support the construction of OpenAPI documents.
+///
+public sealed class OpenApiOptions
+{
+ internal readonly List DocumentTransformers = [];
+
+ ///
+ /// Initializes a new instance of the class
+ /// with the default predicate.
+ ///
+ public OpenApiOptions()
+ {
+ ShouldInclude = (description) => description.GroupName == null || description.GroupName == DocumentName;
+ }
+
+ ///
+ /// The version of the OpenAPI specification to use. Defaults to .
+ ///
+ public OpenApiSpecVersion OpenApiVersion { get; set; } = OpenApiSpecVersion.OpenApi3_0;
+
+ ///
+ /// The name of the OpenAPI document this instance is associated with.
+ ///
+ public string DocumentName { get; internal set; } = OpenApiConstants.DefaultDocumentName;
+
+ ///
+ /// 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/Extensions/ApiDescriptionExtensionsTests.cs b/src/OpenApi/test/Extensions/ApiDescriptionExtensionsTests.cs
new file mode 100644
index 000000000000..cc0d4e872c9a
--- /dev/null
+++ b/src/OpenApi/test/Extensions/ApiDescriptionExtensionsTests.cs
@@ -0,0 +1,74 @@
+// 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;
+using Microsoft.OpenApi.Models;
+
+public class ApiDescriptionExtensionsTests
+{
+ [Theory]
+ [InlineData("api/todos", "/api/todos")]
+ [InlineData("api/todos/{id}", "/api/todos/{id}")]
+ [InlineData("api/todos/{id:int:min(10)}", "/api/todos/{id}")]
+ [InlineData("{a}/{b}/{c=19}", "/{a}/{b}/{c}")]
+ [InlineData("{a}/{b}/{c?}", "/{a}/{b}/{c}")]
+ [InlineData("{a:int}/{b}/{c:int}", "/{a}/{b}/{c}")]
+ [InlineData("", "/")]
+ [InlineData("api", "/api")]
+ [InlineData("{p1}/{p2}.{p3?}", "/{p1}/{p2}.{p3}")]
+ public void MapRelativePathToItemPath_ReturnsItemPathForApiDescription(string relativePath, string expectedItemPath)
+ {
+ // Arrange
+ var apiDescription = new ApiDescription
+ {
+ RelativePath = relativePath
+ };
+
+ // Act
+ var itemPath = apiDescription.MapRelativePathToItemPath();
+
+ // Assert
+ Assert.Equal(expectedItemPath, itemPath);
+ }
+
+ [Theory]
+ [InlineData("GET", OperationType.Get)]
+ [InlineData("POST", OperationType.Post)]
+ [InlineData("PUT", OperationType.Put)]
+ [InlineData("DELETE", OperationType.Delete)]
+ [InlineData("PATCH", OperationType.Patch)]
+ [InlineData("HEAD", OperationType.Head)]
+ [InlineData("OPTIONS", OperationType.Options)]
+ [InlineData("TRACE", OperationType.Trace)]
+ [InlineData("gEt", OperationType.Get)]
+ public void ToOperationType_ReturnsOperationTypeForApiDescription(string httpMethod, OperationType expectedOperationType)
+ {
+ // Arrange
+ var apiDescription = new ApiDescription
+ {
+ HttpMethod = httpMethod
+ };
+
+ // Act
+ var operationType = apiDescription.GetOperationType();
+
+ // Assert
+ Assert.Equal(expectedOperationType, operationType);
+ }
+
+ [Theory]
+ [InlineData("UNKNOWN")]
+ [InlineData("unknown")]
+ public void ToOperationType_ThrowsForUnknownHttpMethod(string methodName)
+ {
+ // Arrange
+ var apiDescription = new ApiDescription
+ {
+ HttpMethod = methodName
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => apiDescription.GetOperationType());
+ Assert.Equal($"Unsupported HTTP method: {methodName}", exception.Message);
+ }
+}
diff --git a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs
new file mode 100644
index 000000000000..d64d6e94be63
--- /dev/null
+++ b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs
@@ -0,0 +1,171 @@
+// 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.Extensions.Hosting;
+using Microsoft.AspNetCore.Routing;
+using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi.Readers;
+using System.Text;
+
+public class OpenApiEndpointRouteBuilderExtensionsTests : OpenApiDocumentServiceTestBase
+{
+ [Fact]
+ public void MapOpenApi_ReturnsEndpointConventionBuilder()
+ {
+ // Arrange
+ var serviceProvider = CreateServiceProvider();
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
+
+ // Act
+ var returnedBuilder = builder.MapOpenApi();
+
+ // Assert
+ Assert.IsAssignableFrom(returnedBuilder);
+ }
+
+ [Fact]
+ public void MapOpenApi_SupportsCustomizingPath()
+ {
+ // Arrange
+ var expectedPath = "/custom/{documentName}/openapi.json";
+ var serviceProvider = CreateServiceProvider();
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
+
+ // Act
+ builder.MapOpenApi(expectedPath);
+
+ // Assert
+ var generatedEndpoint = Assert.IsType(builder.DataSources.First().Endpoints.First());
+ Assert.Equal(expectedPath, generatedEndpoint.RoutePattern.RawText);
+ }
+
+ [Fact]
+ public async Task MapOpenApi_ReturnsRenderedDocument()
+ {
+ // Arrange
+ var serviceProvider = CreateServiceProvider();
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
+ builder.MapOpenApi();
+ var context = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ context.Response.Body = responseBodyStream;
+ context.RequestServices = serviceProvider;
+ context.Request.RouteValues.Add("documentName", "v1");
+ var endpoint = builder.DataSources.First().Endpoints.First();
+
+ // Act
+ var requestDelegate = endpoint.RequestDelegate;
+ await requestDelegate(context);
+
+ // Assert
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ ValidateOpenApiDocument(responseBodyStream, document =>
+ {
+ Assert.Equal("OpenApiEndpointRouteBuilderExtensionsTests | v1", document.Info.Title);
+ Assert.Equal("1.0.0", document.Info.Version);
+ });
+ }
+
+ [Fact]
+ public async Task MapOpenApi_ReturnsDefaultDocumentIfNoNameProvided()
+ {
+ // Arrange
+ var serviceProvider = CreateServiceProvider();
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
+ builder.MapOpenApi("/openapi.json");
+ var context = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ context.Response.Body = responseBodyStream;
+ context.RequestServices = serviceProvider;
+ var endpoint = builder.DataSources.First().Endpoints.First();
+
+ // Act
+ var requestDelegate = endpoint.RequestDelegate;
+ await requestDelegate(context);
+
+ // Assert
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ ValidateOpenApiDocument(responseBodyStream, document =>
+ {
+ Assert.Equal("OpenApiEndpointRouteBuilderExtensionsTests | v1", document.Info.Title);
+ Assert.Equal("1.0.0", document.Info.Version);
+ });
+ }
+
+ [Fact]
+ public async Task MapOpenApi_Returns404ForUnresolvedDocument()
+ {
+ // Arrange
+ var serviceProvider = CreateServiceProvider();
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
+ builder.MapOpenApi();
+ var context = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ context.Response.Body = responseBodyStream;
+ context.RequestServices = serviceProvider;
+ context.Request.RouteValues.Add("documentName", "v2");
+ var endpoint = builder.DataSources.First().Endpoints.First();
+
+ // Act
+ var requestDelegate = endpoint.RequestDelegate;
+ await requestDelegate(context);
+
+ // Assert
+ Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
+ Assert.Equal("No OpenAPI document with the name 'v2' was found.", Encoding.UTF8.GetString(responseBodyStream.ToArray()));
+ }
+
+ [Fact]
+ public async Task MapOpenApi_ReturnsDocumentIfNameProvidedInQuery()
+ {
+ // Arrange
+ var documentName = "v2";
+ var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+ var serviceProviderIsService = new ServiceProviderIsService();
+ var serviceProvider = CreateServiceProvider(documentName);
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
+ builder.MapOpenApi("/openapi.json");
+ var context = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ context.Response.Body = responseBodyStream;
+ context.RequestServices = serviceProvider;
+ context.Request.QueryString = new QueryString($"?documentName={documentName}");
+ var endpoint = builder.DataSources.First().Endpoints.First();
+
+ // Act
+ var requestDelegate = endpoint.RequestDelegate;
+ await requestDelegate(context);
+
+ // Assert
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ ValidateOpenApiDocument(responseBodyStream, document =>
+ {
+ Assert.Equal($"OpenApiEndpointRouteBuilderExtensionsTests | {documentName}", document.Info.Title);
+ Assert.Equal("1.0.0", document.Info.Version);
+ });
+ }
+
+ private static void ValidateOpenApiDocument(MemoryStream documentStream, Action action)
+ {
+ var document = new OpenApiStringReader().Read(Encoding.UTF8.GetString(documentStream.ToArray()), out var diagnostic);
+ Assert.Empty(diagnostic.Errors);
+ action(document);
+ }
+
+ private static IServiceProvider CreateServiceProvider(string documentName = Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultDocumentName)
+ {
+ var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+ var serviceProviderIsService = new ServiceProviderIsService();
+ var serviceProvider = new ServiceCollection()
+ .AddSingleton(serviceProviderIsService)
+ .AddSingleton(hostEnvironment)
+ .AddSingleton(CreateApiDescriptionGroupCollectionProvider())
+ .AddOpenApi(documentName)
+ .BuildServiceProvider();
+ return serviceProvider;
+ }
+}
diff --git a/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs b/src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs
similarity index 100%
rename from src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs
rename to src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs
diff --git a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs
new file mode 100644
index 000000000000..d113642181ef
--- /dev/null
+++ b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs
@@ -0,0 +1,192 @@
+// 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.Extensions.ApiDescriptions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi;
+
+public class OpenApiServiceCollectionExtensions
+{
+ [Fact]
+ public void AddOpenApi_WithDocumentName_ReturnsServiceCollection()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v2";
+
+ // Act
+ var returnedServices = services.AddOpenApi(documentName);
+
+ // Assert
+ Assert.IsAssignableFrom(returnedServices);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithDocumentName_RegistersServices()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v2";
+
+ // Act
+ services.AddOpenApi(documentName);
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Assert
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ Assert.Equal(documentName, namedOption.DocumentName);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithDocumentNameAndConfigureOptions_ReturnsServiceCollection()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v2";
+
+ // Act
+ var returnedServices = services.AddOpenApi(documentName, options => { });
+
+ // Assert
+ Assert.IsAssignableFrom(returnedServices);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithDocumentNameAndConfigureOptions_RegistersServices()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v2";
+
+ // Act
+ services.AddOpenApi(documentName, options => { });
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Assert
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ Assert.Equal(documentName, namedOption.DocumentName);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithoutDocumentName_ReturnsServiceCollection()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act
+ var returnedServices = services.AddOpenApi();
+
+ // Assert
+ Assert.IsAssignableFrom(returnedServices);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithoutDocumentName_RegistersServices()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v1";
+
+ // Act
+ services.AddOpenApi();
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Assert
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ Assert.Equal(documentName, namedOption.DocumentName);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithConfigureOptions_ReturnsServiceCollection()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act
+ var returnedServices = services.AddOpenApi(options => { });
+
+ // Assert
+ Assert.IsAssignableFrom(returnedServices);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithConfigureOptions_RegistersServices()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v1";
+
+ // Act
+ services.AddOpenApi(options => { });
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Assert
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ Assert.Equal(documentName, namedOption.DocumentName);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v2";
+
+ // Act
+ services
+ .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0)
+ .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0);
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Assert
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ Assert.Equal(documentName, namedOption.DocumentName);
+ // Verify last registration is used
+ Assert.Equal(OpenApiSpecVersion.OpenApi3_0, namedOption.OpenApiVersion);
+ }
+
+ [Fact]
+ public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration_ValidateOptionsOverride()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var documentName = "v2";
+
+ // Act
+ services
+ .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0)
+ .AddOpenApi(documentName);
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Assert
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName);
+ Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton);
+ var options = serviceProvider.GetRequiredService>();
+ var namedOption = options.Get(documentName);
+ Assert.Equal(documentName, namedOption.DocumentName);
+ Assert.Equal(OpenApiSpecVersion.OpenApi2_0, namedOption.OpenApiVersion);
+ }
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
index 507534ae26c1..edd26efec6e1 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
@@ -10,6 +10,7 @@
+
@@ -17,4 +18,8 @@
+
+
+
+
diff --git a/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs b/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs
new file mode 100644
index 000000000000..04e31fdec7ba
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs
@@ -0,0 +1,74 @@
+// 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.ApiDescriptions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi.Readers;
+using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests;
+
+public class OpenApiDocumentProviderTests : OpenApiDocumentServiceTestBase
+{
+ [Fact]
+ public async Task GenerateAsync_ReturnsDocument()
+ {
+ // Arrange
+ var documentName = "v1";
+ var serviceProvider = CreateServiceProvider([documentName]);
+ var documentProvider = new OpenApiDocumentProvider(serviceProvider);
+ var stringWriter = new StringWriter();
+
+ // Act
+ await documentProvider.GenerateAsync(documentName, stringWriter);
+
+ // Assert
+ ValidateOpenApiDocument(stringWriter, document =>
+ {
+ Assert.Equal($"{nameof(OpenApiDocumentProviderTests)} | {documentName}", document.Info.Title);
+ Assert.Equal("1.0.0", document.Info.Version);
+ });
+ }
+
+ [Fact]
+ public void GetDocumentNames_ReturnsAllRegisteredDocumentName()
+ {
+ // Arrange
+ var serviceProvider = CreateServiceProvider(["v2", "internal", "public", "v1"]);
+ var documentProvider = new OpenApiDocumentProvider(serviceProvider);
+
+ // Act
+ var documentNames = documentProvider.GetDocumentNames();
+
+ // Assert
+ Assert.Equal(4, documentNames.Count());
+ Assert.Collection(documentNames,
+ x => Assert.Equal("v2", x),
+ x => Assert.Equal("internal", x),
+ x => Assert.Equal("public", x),
+ x => Assert.Equal("v1", x));
+ }
+
+ private static void ValidateOpenApiDocument(StringWriter stringWriter, Action action)
+ {
+ var document = new OpenApiStringReader().Read(stringWriter.ToString(), out var diagnostic);
+ Assert.Empty(diagnostic.Errors);
+ action(document);
+ }
+
+ private static IServiceProvider CreateServiceProvider(string[] documentNames)
+ {
+ var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiDocumentProviderTests) };
+ var serviceProviderIsService = new ServiceProviderIsService();
+ var serviceCollection = new ServiceCollection()
+ .AddSingleton(serviceProviderIsService)
+ .AddSingleton(hostEnvironment)
+ .AddSingleton(CreateApiDescriptionGroupCollectionProvider());
+ foreach (var documentName in documentNames)
+ {
+ serviceCollection.AddOpenApi(documentName);
+ }
+ var serviceProvider = serviceCollection.BuildServiceProvider();
+ return serviceProvider;
+ }
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs
new file mode 100644
index 000000000000..3f201381c0f7
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs
@@ -0,0 +1,56 @@
+// 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;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting.Internal;
+using Microsoft.Extensions.Options;
+using Moq;
+
+public partial class OpenApiDocumentServiceTests
+{
+ [Fact]
+ public void GetOpenApiInfo_RespectsHostEnvironmentName()
+ {
+ // Arrange
+ var hostEnvironment = new HostingEnvironment
+ {
+ ApplicationName = "TestApplication"
+ };
+ var docService = new OpenApiDocumentService(
+ "v1",
+ new Mock().Object,
+ hostEnvironment,
+ new Mock>().Object,
+ new Mock().Object);
+
+ // Act
+ var info = docService.GetOpenApiInfo();
+
+ // Assert
+ Assert.Equal("TestApplication | v1", info.Title);
+ }
+
+ [Fact]
+ public void GetOpenApiInfo_RespectsDocumentName()
+ {
+ // Arrange
+ var hostEnvironment = new HostingEnvironment
+ {
+ ApplicationName = "TestApplication"
+ };
+ var docService = new OpenApiDocumentService(
+ "v2",
+ new Mock().Object,
+ hostEnvironment,
+ new Mock>().Object,
+ new Mock().Object);
+
+ // Act
+ var info = docService.GetOpenApiInfo();
+
+ // Assert
+ Assert.Equal("TestApplication | v2", info.Title);
+ }
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs
new file mode 100644
index 000000000000..18f5023f9b0a
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs
@@ -0,0 +1,181 @@
+// 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.Http;
+using Microsoft.OpenApi.Models;
+
+public partial class OpenApiDocumentServiceTests
+{
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesSummary()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+ var summary = "Get all todos";
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithSummary(summary);
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Equal(summary, operation.Summary);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesLastSummary()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+ var summary = "Get all todos";
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithSummary(summary).WithSummary(summary + "1");
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Equal(summary + "1", operation.Summary);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesDescription()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+ var description = "Returns all the todos provided in an array.";
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithDescription(description);
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Equal(description, operation.Description);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesDescriptionLastDescription()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+ var description = "Returns all the todos provided in an array.";
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithDescription(description).WithDescription(description + "1");
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Equal(description + "1", operation.Description);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesTags()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]);
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Collection(operation.Tags, tag =>
+ {
+ Assert.Equal("todos", tag.Name);
+ },
+ tag =>
+ {
+ Assert.Equal("v1", tag.Name);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesTagsLastTags()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]).WithTags(["todos", "v2"]);
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Collection(operation.Tags, tag =>
+ {
+ Assert.Equal("todos", tag.Name);
+ },
+ tag =>
+ {
+ Assert.Equal("v2", tag.Name);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_SetsDefaultValueForTags()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
+ Assert.Collection(document.Tags, tag =>
+ {
+ Assert.Equal(nameof(OpenApiDocumentServiceTests), tag.Name);
+ });
+ Assert.Collection(operation.Tags, tag =>
+ {
+ Assert.Equal(nameof(OpenApiDocumentServiceTests), tag.Name);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiOperation_CapturesTagsInDocument()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]);
+ builder.MapGet("/api/users", () => { }).WithTags(["users", "v1"]);
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ Assert.Collection(document.Tags, tag =>
+ {
+ Assert.Equal("todos", tag.Name);
+ },
+ tag =>
+ {
+ Assert.Equal("v1", tag.Name);
+ },
+ tag =>
+ {
+ Assert.Equal("users", tag.Name);
+ });
+ });
+ }
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs
new file mode 100644
index 000000000000..3610923576e1
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs
@@ -0,0 +1,138 @@
+// 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.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.OpenApi.Models;
+
+public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase
+{
+ [Fact]
+ public async Task GetOpenApiParameters_GeneratesParameterLocationCorrectly()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos/{id}", (int id) => { });
+ builder.MapGet("/api/todos", (int id) => { });
+ builder.MapGet("/api", ([FromHeader(Name = "X-Header")] string header) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var pathParameter = Assert.Single(document.Paths["/api/todos/{id}"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("id", pathParameter.Name);
+ Assert.Equal(ParameterLocation.Path, pathParameter.In);
+
+ var queryParameter = Assert.Single(document.Paths["/api/todos"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("id", queryParameter.Name);
+ Assert.Equal(ParameterLocation.Query, queryParameter.In);
+
+ var headerParameter = Assert.Single(document.Paths["/api"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("X-Header", headerParameter.Name);
+ Assert.Equal(ParameterLocation.Header, headerParameter.In);
+ });
+ }
+
+#nullable enable
+ [Fact]
+ public async Task GetOpenApiParameters_RouteParametersAreAlwaysRequired()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos/{id}", (int id) => { });
+ builder.MapGet("/api/todos/{guid}", (Guid? guid) => { });
+ builder.MapGet("/api/todos/{isCompleted}", (bool isCompleted = false) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var pathParameter = Assert.Single(document.Paths["/api/todos/{id}"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("id", pathParameter.Name);
+ Assert.True(pathParameter.Required);
+ var guidParameter = Assert.Single(document.Paths["/api/todos/{guid}"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("guid", guidParameter.Name);
+ Assert.True(guidParameter.Required);
+ var isCompletedParameter = Assert.Single(document.Paths["/api/todos/{isCompleted}"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("isCompleted", isCompletedParameter.Name);
+ Assert.True(isCompletedParameter.Required);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiParameters_SetsRequirednessForQueryParameters()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", (int id) => { });
+ builder.MapGet("/api/users", (int? id) => { });
+ builder.MapGet("/api/projects", (int id = 1) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var queryParameter = Assert.Single(document.Paths["/api/todos"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("id", queryParameter.Name);
+ Assert.True(queryParameter.Required);
+ var nullableQueryParameter = Assert.Single(document.Paths["/api/users"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("id", nullableQueryParameter.Name);
+ Assert.False(nullableQueryParameter.Required);
+ var defaultQueryParameter = Assert.Single(document.Paths["/api/projects"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("id", defaultQueryParameter.Name);
+ Assert.False(defaultQueryParameter.Required);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiParameters_SetsRequirednessForHeaderParameters()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", ([FromHeader(Name = "X-Header")] string header) => { });
+ builder.MapGet("/api/users", ([FromHeader(Name = "X-Header")] Guid? header) => { });
+ builder.MapGet("/api/projects", ([FromHeader(Name = "X-Header")] string header = "0000-0000-0000-0000") => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var headerParameter = Assert.Single(document.Paths["/api/todos"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("X-Header", headerParameter.Name);
+ Assert.True(headerParameter.Required);
+ var nullableHeaderParameter = Assert.Single(document.Paths["/api/users"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("X-Header", nullableHeaderParameter.Name);
+ Assert.False(nullableHeaderParameter.Required);
+ var defaultHeaderParameter = Assert.Single(document.Paths["/api/projects"].Operations[OperationType.Get].Parameters);
+ Assert.Equal("X-Header", defaultHeaderParameter.Name);
+ Assert.False(defaultHeaderParameter.Required);
+ });
+ }
+#nullable restore
+
+ [Fact]
+ public async Task GetOpenApiRequestBody_SkipsRequestBodyParameters()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/api/users", (IFormFile formFile, IFormFileCollection formFiles) => { });
+ builder.MapPost("/api/todos", (Todo todo) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var usersOperation = document.Paths["/api/users"].Operations[OperationType.Post];
+ Assert.Null(usersOperation.Parameters);
+ var todosOperation = document.Paths["/api/todos"].Operations[OperationType.Post];
+ Assert.Null(todosOperation.Parameters);
+ });
+ }
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs
new file mode 100644
index 000000000000..a74353f762f0
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs
@@ -0,0 +1,171 @@
+// 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.Routing;
+using Microsoft.OpenApi.Models;
+
+public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase
+{
+ [Fact]
+ public async Task GetOpenApiPaths_ReturnsPaths()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { });
+ builder.MapGet("/api/users", () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ Assert.Collection(document.Paths.OrderBy(p => p.Key),
+ path =>
+ {
+ Assert.Equal("/api/todos", path.Key);
+ Assert.Collection(path.Value.Operations.OrderBy(o => o.Key),
+ operation =>
+ {
+ Assert.Equal(OperationType.Get, operation.Key);
+ });
+ },
+ path =>
+ {
+ Assert.Equal("/api/users", path.Key);
+ Assert.Collection(path.Value.Operations.OrderBy(o => o.Key),
+ operation =>
+ {
+ Assert.Equal(OperationType.Get, operation.Key);
+ });
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiPaths_RespectsShouldInclude()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { }).WithMetadata(new EndpointGroupNameAttribute("v1"));
+ builder.MapGet("/api/users", () => { }).WithMetadata(new EndpointGroupNameAttribute("v2"));
+
+ // Assert -- The default `ShouldInclude` implementation only includes endpoints that
+ // match the document name. Since we don't set a document name explicitly, this will
+ // match against the default document name ("v1") and the document will only contain
+ // the endpoint with that group name.
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ Assert.Collection(document.Paths.OrderBy(p => p.Key),
+ path =>
+ {
+ Assert.Equal("/api/todos", path.Key);
+ }
+ );
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiPaths_RespectsSamePaths()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { });
+ builder.MapPost("/api/todos", () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ Assert.Collection(document.Paths.OrderBy(p => p.Key),
+ path =>
+ {
+ Assert.Equal("/api/todos", path.Key);
+ Assert.Collection(path.Value.Operations.OrderBy(o => o.Key),
+ operation =>
+ {
+ Assert.Equal(OperationType.Get, operation.Key);
+ },
+ operation =>
+ {
+ Assert.Equal(OperationType.Post, operation.Key);
+ });
+ }
+ );
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiPaths_HandlesRouteParameters()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos/{id}", () => { });
+ builder.MapPost("/api/todos/{id}", () => { });
+ builder.MapMethods("/api/todos/{id}", ["PATCH", "PUT"], () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ Assert.Collection(document.Paths.OrderBy(p => p.Key),
+ path =>
+ {
+ Assert.Equal("/api/todos/{id}", path.Key);
+ Assert.Collection(path.Value.Operations.OrderBy(o => o.Key),
+ operation =>
+ {
+ Assert.Equal(OperationType.Get, operation.Key);
+ },
+ operation =>
+ {
+ Assert.Equal(OperationType.Put, operation.Key);
+ },
+ operation =>
+ {
+ Assert.Equal(OperationType.Post, operation.Key);
+ },
+ operation =>
+ {
+ Assert.Equal(OperationType.Patch, operation.Key);
+ });
+ }
+ );
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiPaths_HandlesRouteConstraints()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos/{id:int}", () => { });
+ builder.MapPost("/api/todos/{id:int}", () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ Assert.Collection(document.Paths.OrderBy(p => p.Key),
+ path =>
+ {
+ Assert.Equal("/api/todos/{id}", path.Key);
+ Assert.Collection(path.Value.Operations.OrderBy(o => o.Key),
+ operation =>
+ {
+ Assert.Equal(OperationType.Get, operation.Key);
+ },
+ operation =>
+ {
+ Assert.Equal(OperationType.Post, operation.Key);
+ });
+ }
+ );
+ });
+ }
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs
new file mode 100644
index 000000000000..16604a533fe8
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs
@@ -0,0 +1,391 @@
+// 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.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.OpenApi.Models;
+
+public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase
+{
+ [Fact]
+ public async Task GetRequestBody_VerifyDefaultFormEncoding()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", (IFormFile formFile) => { });
+
+ // Assert -- The defaults for form encoding are Explode = true and Style = Form
+ // which align with the encoding formats that are used by ASP.NET Core's binding layer.
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("multipart/form-data", content.Key);
+ var encoding = content.Value.Encoding["multipart/form-data"];
+ Assert.True(encoding.Explode);
+ Assert.Equal(ParameterStyle.Form, encoding.Style);
+ });
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetRequestBody_HandlesIFormFile(bool withAttribute)
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ if (withAttribute)
+ {
+ builder.MapPost("/", ([FromForm] IFormFile formFile) => { });
+ }
+ else
+ {
+ builder.MapPost("/", (IFormFile formFile) => { });
+ }
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.False(operation.RequestBody.Required);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("multipart/form-data", content.Key);
+ Assert.Equal("object", content.Value.Schema.Type);
+ Assert.NotNull(content.Value.Schema.Properties);
+ Assert.Contains("formFile", content.Value.Schema.Properties);
+ var formFileProperty = content.Value.Schema.Properties["formFile"];
+ Assert.Equal("string", formFileProperty.Type);
+ Assert.Equal("binary", formFileProperty.Format);
+ });
+ }
+
+#nullable enable
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetRequestBody_HandlesIFormFileOptionality(bool isOptional)
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ if (isOptional)
+ {
+ builder.MapPost("/", (IFormFile? formFile) => { });
+ }
+ else
+ {
+ builder.MapPost("/", (IFormFile formFile) => { });
+ }
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.Equal(!isOptional, operation.RequestBody.Required);
+ });
+ }
+#nullable restore
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetRequestBody_HandlesIFormFileCollection(bool withAttribute)
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ if (withAttribute)
+ {
+ builder.MapPost("/", ([FromForm] IFormFileCollection formFileCollection) => { });
+ }
+ else
+ {
+ builder.MapPost("/", (IFormFileCollection formFileCollection) => { });
+ }
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.False(operation.RequestBody.Required);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("multipart/form-data", content.Key);
+ Assert.Equal("object", content.Value.Schema.Type);
+ Assert.NotNull(content.Value.Schema.Properties);
+ Assert.Contains("formFileCollection", content.Value.Schema.Properties);
+ var formFileProperty = content.Value.Schema.Properties["formFileCollection"];
+ Assert.Equal("array", formFileProperty.Type);
+ Assert.Equal("string", formFileProperty.Items.Type);
+ Assert.Equal("binary", formFileProperty.Items.Format);
+ });
+ }
+
+#nullable enable
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetRequestBody_HandlesIFormFileCollectionOptionality(bool isOptional)
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ if (isOptional)
+ {
+ builder.MapPost("/", (IFormFileCollection? formFile) => { });
+ }
+ else
+ {
+ builder.MapPost("/", (IFormFileCollection formFile) => { });
+ }
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.Equal(!isOptional, operation.RequestBody.Required);
+ });
+ }
+#nullable restore
+
+ [Fact]
+ public async Task GetRequestBody_MultipleFormFileParameters()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", (IFormFile formFile1, IFormFile formFile2) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("multipart/form-data", content.Key);
+ Assert.Equal("object", content.Value.Schema.Type);
+ Assert.NotNull(content.Value.Schema.Properties);
+ Assert.Contains("formFile1", content.Value.Schema.Properties);
+ Assert.Contains("formFile2", content.Value.Schema.Properties);
+ var formFile1Property = content.Value.Schema.Properties["formFile1"];
+ Assert.Equal("string", formFile1Property.Type);
+ Assert.Equal("binary", formFile1Property.Format);
+ var formFile2Property = content.Value.Schema.Properties["formFile2"];
+ Assert.Equal("string", formFile2Property.Type);
+ Assert.Equal("binary", formFile2Property.Format);
+ });
+ }
+
+ [Fact]
+ public async Task GetRequestBody_IFormFileHandlesAcceptsMetadata()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", (IFormFile formFile) => { }).Accepts(typeof(IFormFile), "application/magic-foo-content-type");
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("application/magic-foo-content-type", content.Key);
+ Assert.Equal("object", content.Value.Schema.Type);
+ Assert.NotNull(content.Value.Schema.Properties);
+ Assert.Contains("formFile", content.Value.Schema.Properties);
+ var formFileProperty = content.Value.Schema.Properties["formFile"];
+ Assert.Equal("string", formFileProperty.Type);
+ Assert.Equal("binary", formFileProperty.Format);
+ });
+ }
+
+ [Fact]
+ public async Task GetRequestBody_IFormFileHandlesConsumesAttribute()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", [Consumes(typeof(IFormFile), "application/magic-foo-content-type")] (IFormFile formFile) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("application/magic-foo-content-type", content.Key);
+ Assert.Equal("object", content.Value.Schema.Type);
+ Assert.NotNull(content.Value.Schema.Properties);
+ Assert.Contains("formFile", content.Value.Schema.Properties);
+ var formFileProperty = content.Value.Schema.Properties["formFile"];
+ Assert.Equal("string", formFileProperty.Type);
+ Assert.Equal("binary", formFileProperty.Format);
+ });
+ }
+
+ [Fact]
+ public async Task GetRequestBody_HandlesJsonBody()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", (TodoWithDueDate name) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.False(operation.RequestBody.Required);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("application/json", content.Key);
+ });
+ }
+
+#nullable enable
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetRequestBody_HandlesJsonBodyOptionality(bool isOptional)
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ if (isOptional)
+ {
+ builder.MapPost("/", (TodoWithDueDate? name) => { });
+ }
+ else
+ {
+ builder.MapPost("/", (TodoWithDueDate name) => { });
+ }
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.Equal(!isOptional, operation.RequestBody.Required);
+ });
+
+ }
+#nullable restore
+
+ [Fact]
+ public async Task GetRequestBody_HandlesJsonBodyWithAttribute()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", ([FromBody] string name) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.False(operation.RequestBody.Required);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("application/json", content.Key);
+ });
+ }
+
+ [Fact]
+ public async Task GetRequestBody_HandlesJsonBodyWithAcceptsMetadata()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", (string name) => { }).Accepts(typeof(string), "application/magic-foo-content-type");
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("application/magic-foo-content-type", content.Key);
+ });
+ }
+
+ [Fact]
+ public async Task GetRequestBody_HandlesJsonBodyWithConsumesAttribute()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", [Consumes(typeof(string), "application/magic-foo-content-type")] (string name) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.NotNull(operation.RequestBody);
+ Assert.NotNull(operation.RequestBody.Content);
+ var content = Assert.Single(operation.RequestBody.Content);
+ Assert.Equal("application/magic-foo-content-type", content.Key);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiRequestBody_SetsNullRequestBodyWithNoParameters()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapPost("/", (string name) => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var paths = Assert.Single(document.Paths.Values);
+ var operation = paths.Operations[OperationType.Post];
+ Assert.Null(operation.RequestBody);
+ });
+ }
+
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs
new file mode 100644
index 000000000000..640073eeebc9
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs
@@ -0,0 +1,257 @@
+// 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.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.OpenApi.Models;
+
+public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase
+{
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsMultipleResponseViaAttributes()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos",
+ [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ Assert.Collection(operation.Responses.OrderBy(r => r.Key),
+ response =>
+ {
+ Assert.Equal("201", response.Key);
+ Assert.Equal("Created", response.Value.Description);
+ },
+ response =>
+ {
+ Assert.Equal("400", response.Key);
+ Assert.Equal("Bad Request", response.Value.Description);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsProblemDetailsResponse()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { })
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status400BadRequest, typeof(ProblemDetails), ["application/json+problem"]));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("400", response.Key);
+ Assert.Equal("Bad Request", response.Value.Description);
+ Assert.Equal("application/json+problem", response.Value.Content.Keys.Single());
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsMultipleResponsesForStatusCode()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { })
+ // Simulates metadata provided by IEndpointMetadataProvider
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK))
+ // Simulates metadata added via `Produces` call
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(string), ["text/plain"]));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document => {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("200", response.Key);
+ Assert.Equal("OK", response.Value.Description);
+ var content = Assert.Single(response.Value.Content);
+ Assert.Equal("text/plain", content.Key);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsMultipleResponseTypesWithTypeForStatusCode()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { })
+ // Simulates metadata provided by IEndpointMetadataProvider
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"]))
+ // Simulates metadata added via `Produces` call
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"]));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document => {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("200", response.Key);
+ Assert.Equal("OK", response.Value.Description);
+ var content = Assert.Single(response.Value.Content);
+ Assert.Equal("application/json", content.Key);
+ // Todo: Check that this generates a schema using `oneOf`.
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsMultipleResponseTypesWitDifferentContentTypes()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { })
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json", "application/xml"]));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("200", response.Key);
+ Assert.Equal("OK", response.Value.Description);
+ Assert.Collection(response.Value.Content.OrderBy(c => c.Key),
+ content =>
+ {
+ Assert.Equal("application/json", content.Key);
+ },
+ content =>
+ {
+ Assert.Equal("application/xml", content.Key);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsDifferentResponseTypesWitDifferentContentTypes()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { })
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"]))
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/xml"]));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("200", response.Key);
+ Assert.Equal("OK", response.Value.Description);
+ Assert.Collection(response.Value.Content.OrderBy(c => c.Key),
+ content =>
+ {
+ Assert.Equal("application/xml", content.Key);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_ProducesDefaultResponse()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("200", response.Key);
+ Assert.Equal("OK", response.Value.Description);
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsMvcProducesAttribute()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", [Produces("application/json", "application/xml")] () => new Todo(1, "Test todo", false, DateTime.Now));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal("200", response.Key);
+ Assert.Equal("OK", response.Value.Description);
+ Assert.Collection(response.Value.Content.OrderBy(c => c.Key),
+ content =>
+ {
+ Assert.Equal("application/json", content.Key);
+ },
+ content =>
+ {
+ Assert.Equal("application/xml", content.Key);
+ });
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsGeneratingDefaultResponseField()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", [ProducesDefaultResponseType(typeof(Error))] () => { });
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var response = Assert.Single(operation.Responses);
+ Assert.Equal(Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultOpenApiResponseKey, response.Key);
+ Assert.Empty(response.Value.Description);
+ // Todo: Validate generated schema.
+ });
+ }
+
+ [Fact]
+ public async Task GetOpenApiResponse_SupportsGeneratingDefaultResponseWithSuccessResponse()
+ {
+ // Arrange
+ var builder = CreateBuilder();
+
+ // Act
+ builder.MapGet("/api/todos", [ProducesDefaultResponseType(typeof(Error))] () => { })
+ .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"]));
+
+ // Assert
+ await VerifyOpenApiDocument(builder, document =>
+ {
+ var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
+ var defaultResponse = operation.Responses[Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultOpenApiResponseKey];
+ Assert.NotNull(defaultResponse);
+ Assert.Empty(defaultResponse.Description);
+ var okResponse = operation.Responses["200"];
+ Assert.NotNull(okResponse);
+ Assert.Equal("OK", okResponse.Description);
+ Assert.Equal("application/json", Assert.Single(okResponse.Content).Key);
+ // Todo: Validate generated schema.
+ });
+ }
+}
diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs
new file mode 100644
index 000000000000..ac3bd3b6e3d4
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs
@@ -0,0 +1,139 @@
+// 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.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Models;
+using Moq;
+using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests;
+
+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([]);
+
+ var endpointDataSource = builder.DataSources.OfType().Single();
+ var hostEnvironment = new HostEnvironment
+ {
+ ApplicationName = nameof(OpenApiDocumentServiceTests)
+ };
+ var options = new Mock>();
+ options.Setup(o => o.Get(It.IsAny())).Returns(openApiOptions);
+
+ var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource);
+ provider.OnProvidersExecuting(context);
+ provider.OnProvidersExecuted(context);
+
+ var apiDescriptionGroupCollectionProvider = CreateApiDescriptionGroupCollectionProvider(context.Results);
+
+ 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)
+ {
+ var apiDescriptionGroup = new ApiDescriptionGroup("testGroupName", (apiDescriptions ?? Array.Empty()).AsReadOnly());
+ var apiDescriptionGroupCollection = new ApiDescriptionGroupCollection([apiDescriptionGroup], 1);
+ var apiDescriptionGroupCollectionProvider = new Mock();
+ apiDescriptionGroupCollectionProvider.Setup(p => p.ApiDescriptionGroups).Returns(apiDescriptionGroupCollection);
+ return apiDescriptionGroupCollectionProvider.Object;
+ }
+
+ private static EndpointMetadataApiDescriptionProvider CreateEndpointMetadataApiDescriptionProvider(EndpointDataSource endpointDataSource) => new EndpointMetadataApiDescriptionProvider(
+ endpointDataSource,
+ new HostEnvironment { ApplicationName = nameof(OpenApiDocumentServiceTests) },
+ new DefaultParameterPolicyFactory(Options.Create(new RouteOptions()), new TestServiceProvider()),
+ new ServiceProviderIsService());
+
+ 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
+ {
+ public TestEndpointRouteBuilder(IApplicationBuilder applicationBuilder)
+ {
+ ApplicationBuilder = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder));
+ DataSources = new List();
+ }
+
+ public IApplicationBuilder ApplicationBuilder { get; }
+
+ public IApplicationBuilder CreateApplicationBuilder() => ApplicationBuilder.New();
+
+ public ICollection DataSources { get; }
+
+ public IServiceProvider ServiceProvider => ApplicationBuilder.ApplicationServices;
+ }
+
+ private class TestServiceProvider : IServiceProvider, IKeyedServiceProvider
+ {
+ public static TestServiceProvider Instance { get; } = new TestServiceProvider();
+ private IKeyedServiceProvider _serviceProvider;
+ internal OpenApiDocumentService TestDocumentService { get; set; }
+ internal OpenApiComponentService TestComponentService { get; set; } = new OpenApiComponentService();
+
+ public void SetInternalServiceProvider(IServiceCollection serviceCollection)
+ {
+ _serviceProvider = serviceCollection.BuildServiceProvider();
+ }
+
+ public object GetKeyedService(Type serviceType, object serviceKey)
+ {
+ if (serviceType == typeof(OpenApiDocumentService))
+ {
+ return TestDocumentService;
+ }
+ if (serviceType == typeof(OpenApiComponentService))
+ {
+ return TestComponentService;
+ }
+
+ return _serviceProvider.GetKeyedService(serviceType, serviceKey);
+ }
+
+ public object GetRequiredKeyedService(Type serviceType, object serviceKey)
+ {
+ if (serviceType == typeof(OpenApiDocumentService))
+ {
+ return TestDocumentService;
+ }
+ if (serviceType == typeof(OpenApiComponentService))
+ {
+ return TestComponentService;
+ }
+
+ return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey);
+ }
+
+ public object GetService(Type serviceType)
+ {
+ if (serviceType == typeof(IOptions))
+ {
+ return Options.Create(new RouteHandlerOptions());
+ }
+
+ return _serviceProvider.GetService(serviceType);
+ }
+ }
+}
diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/Services/OpenApiGeneratorTests.cs
similarity index 100%
rename from src/OpenApi/test/OpenApiGeneratorTests.cs
rename to src/OpenApi/test/Services/OpenApiGeneratorTests.cs
diff --git a/src/OpenApi/test/SharedTypes.cs b/src/OpenApi/test/SharedTypes.cs
new file mode 100644
index 000000000000..4689aa2a58f8
--- /dev/null
+++ b/src/OpenApi/test/SharedTypes.cs
@@ -0,0 +1,11 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+// This file contains shared types that are used across tests, sample apps,
+// and benchmark apps.
+
+public record Todo(int Id, string Title, bool Completed, DateTime CreatedAt);
+
+public record TodoWithDueDate(int Id, string Title, bool Completed, DateTime CreatedAt, DateTime DueDate) : Todo(Id, Title, Completed, CreatedAt);
+
+public record Error(int code, string Message);
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);
+ }
+}