Browse Source

Merge feature/openapi to main (#55182)

Co-authored-by: Martin Costello <[email protected]>
Co-authored-by: Rick Anderson <[email protected]>

This PR adds support for OpenAPI document generation, sans schema generation to Microsoft.AspNetCore.OpenApi. Relevant changes are available in individual PRs:

- Add entry-point APIs for OpenAPI support (#54789)
- Support resolving OpenApiPaths entries from document (#54847) 
- Support generating OpenAPI operation and associated fields (#54903) 
- Add APIs for OpenAPI document transformers (#54935) 
- Add support for generating OpenAPI parameters (#55041)
- Add support for generating OpenAPI responses (#55020) 
- Add support for generating OpenAPI request bodies (#55040)
Safia Abdalla 1 year ago
parent
commit
534946e040
57 changed files with 3901 additions and 5 deletions
  1. 41 0
      AspNetCore.sln
  2. 1 0
      eng/Dependencies.props
  3. 1 0
      eng/Versions.props
  4. 0 2
      src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs
  5. 1 0
      src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
  6. 4 2
      src/OpenApi/OpenApi.slnf
  7. 4 0
      src/OpenApi/perf/Microbenchmarks/AssemblyInfo.cs
  8. 44 0
      src/OpenApi/perf/Microbenchmarks/GenerationBenchmarks.cs
  9. 23 0
      src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj
  10. 93 0
      src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs
  11. 46 0
      src/OpenApi/sample/EndpointRouteBuilderExtensions.cs
  12. 70 0
      src/OpenApi/sample/Program.cs
  13. 38 0
      src/OpenApi/sample/Properties/launchSettings.json
  14. 25 0
      src/OpenApi/sample/Sample.csproj
  15. 31 0
      src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs
  16. 20 0
      src/OpenApi/sample/Transformers/AddContactTransformer.cs
  17. 28 0
      src/OpenApi/sample/Transformers/OperationTransformers.cs
  18. 8 0
      src/OpenApi/sample/appsettings.Development.json
  19. 9 0
      src/OpenApi/sample/appsettings.json
  20. 116 0
      src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs
  21. 0 0
      src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs
  22. 65 0
      src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs
  23. 72 0
      src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs
  24. 34 0
      src/OpenApi/src/Helpers/OpenApiTagComparer.cs
  25. 11 1
      src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
  26. 36 0
      src/OpenApi/src/PublicAPI.Unshipped.txt
  27. 23 0
      src/OpenApi/src/Services/IDocumentProvider.cs
  28. 18 0
      src/OpenApi/src/Services/NamedService.cs
  29. 38 0
      src/OpenApi/src/Services/OpenApiComponentService.cs
  30. 13 0
      src/OpenApi/src/Services/OpenApiConstants.cs
  31. 46 0
      src/OpenApi/src/Services/OpenApiDocumentProvider.cs
  32. 337 0
      src/OpenApi/src/Services/OpenApiDocumentService.cs
  33. 0 0
      src/OpenApi/src/Services/OpenApiGenerator.cs
  34. 92 0
      src/OpenApi/src/Services/OpenApiOptions.cs
  35. 79 0
      src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs
  36. 21 0
      src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs
  37. 27 0
      src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs
  38. 27 0
      src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs
  39. 34 0
      src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs
  40. 74 0
      src/OpenApi/test/Extensions/ApiDescriptionExtensionsTests.cs
  41. 171 0
      src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs
  42. 0 0
      src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs
  43. 192 0
      src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs
  44. 5 0
      src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
  45. 74 0
      src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs
  46. 56 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs
  47. 181 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs
  48. 138 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs
  49. 171 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs
  50. 391 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs
  51. 257 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs
  52. 139 0
      src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs
  53. 0 0
      src/OpenApi/test/Services/OpenApiGeneratorTests.cs
  54. 11 0
      src/OpenApi/test/SharedTypes.cs
  55. 230 0
      src/OpenApi/test/Transformers/DocumentTransformerTests.cs
  56. 87 0
      src/OpenApi/test/Transformers/OpenApiOptionsTests.cs
  57. 148 0
      src/OpenApi/test/Transformers/OperationTransformerTests.cs

+ 41 - 0
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}

+ 1 - 0
eng/Dependencies.props

@@ -69,6 +69,7 @@ and are generated based on the last package release.
     <LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" />
     <LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" />
     <LatestPackageReference Include="Microsoft.OpenApi" />
+    <LatestPackageReference Include="Microsoft.OpenApi.Readers" />
     <LatestPackageReference Include="System.Buffers" />
     <LatestPackageReference Include="System.CodeDom" />
     <LatestPackageReference Include="System.CommandLine.Experimental" />

+ 1 - 0
eng/Versions.props

@@ -335,6 +335,7 @@
     <MicrosoftDataSqlClientVersion>4.0.5</MicrosoftDataSqlClientVersion>
     <MicrosoftAspNetCoreAppVersion>6.0.0-preview.3.21167.1</MicrosoftAspNetCoreAppVersion>
     <MicrosoftOpenApiVersion>1.6.13</MicrosoftOpenApiVersion>
+    <MicrosoftOpenApiReadersVersion>1.6.13</MicrosoftOpenApiReadersVersion>
     <!-- dotnet tool versions (see also auto-updated DotnetEfVersion property). -->
     <DotnetDumpVersion>6.0.322601</DotnetDumpVersion>
     <DotnetServeVersion>1.10.93</DotnetServeVersion>

+ 0 - 2
src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs

@@ -12,8 +12,6 @@ using Microsoft.AspNetCore.Http.Metadata;
 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;
 

+ 1 - 0
src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj

@@ -20,5 +20,6 @@
 
   <ItemGroup>
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.ApiExplorer.Test" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Tests" />
   </ItemGroup>
 </Project>

+ 4 - 2
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"
     ]
   }
-}
+}

+ 4 - 0
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]

+ 44 - 0
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;
+
+/// <summary>
+/// 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.
+/// </summary>
+[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();
+    }
+}

+ 23 - 0
src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj

@@ -0,0 +1,23 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <OutputType>Exe</OutputType>
+    <PreserveCompilationContext>true</PreserveCompilationContext>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="BenchmarkDotNet" />
+    <Reference Include="Microsoft.AspNetCore" />
+    <Reference Include="Microsoft.AspNetCore.OpenApi" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\test\Microsoft.AspNetCore.OpenApi.Tests.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)BenchmarkRunner\*.cs" />
+  </ItemGroup>
+
+</Project>

+ 93 - 0
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;
+
+/// <summary>
+/// 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.
+/// </summary>
+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<ActivatedTransformer>();
+        }
+        _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;
+        }
+    }
+}

+ 46 - 0
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
+{
+    /// <summary>
+    ///  Helper method to render Swagger UI view for testing.
+    /// </summary>
+    public static IEndpointConventionBuilder MapSwaggerUi(this IEndpointRouteBuilder endpoints)
+    {
+        return endpoints.MapGet("/swagger/{documentName}", (string documentName) => Results.Content($$"""
+    <html>
+    <head>
+        <meta charset="UTF-8">
+        <title>OpenAPI -- {{documentName}}</title>
+        <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css">
+    </head>
+    <body>
+        <div id="swagger-ui"></div>
+
+        <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>
+        <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
+
+        <script>
+            window.onload = function() {
+                const ui = SwaggerUIBundle({
+                url: "/openapi/{{documentName}}.json",
+                    dom_id: '#swagger-ui',
+                    deepLinking: true,
+                    presets: [
+                        SwaggerUIBundle.presets.apis,
+                        SwaggerUIStandalonePreset
+                    ],
+                    plugins: [
+                        SwaggerUIBundle.plugins.DownloadUrl
+                    ],
+                    layout: "StandaloneLayout",
+                })
+                window.ui = ui
+            }
+        </script>
+    </body>
+    </html>
+    """, "text/html")).ExcludeFromDescription();
+    }
+}

+ 70 - 0
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<BearerSecuritySchemeTransformer>();
+});
+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<Todo>(additionalContentTypes: "text/xml");
+
+responses.MapGet("/200-only-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
+    .Produces<Todo>(contentType: "text/xml");
+
+app.Run();

+ 38 - 0
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"
+      }
+    }
+  }
+}

+ 25 - 0
src/OpenApi/sample/Sample.csproj

@@ -0,0 +1,25 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore" />
+    <Reference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
+    <Reference Include="Microsoft.AspNetCore.Hosting" />
+    <Reference Include="Microsoft.AspNetCore.OpenApi" />
+    <Reference Include="Microsoft.AspNetCore.Http" />
+    <Reference Include="Microsoft.AspNetCore.Http.Results" />
+    <Reference Include="Microsoft.AspNetCore.StaticFiles" />
+    <Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
+    <Reference Include="Microsoft.AspNetCore.Mvc" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Compile Include="../test/SharedTypes.cs" />
+  </ItemGroup>
+
+</Project>

+ 31 - 0
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<string, OpenApiSecurityScheme>
+            {
+                ["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;
+        }
+    }
+}

+ 20 - 0
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 = "[email protected]"
+        };
+        return Task.CompletedTask;
+    }
+}

+ 28 - 0
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;
+        });
+    }
+}

+ 8 - 0
src/OpenApi/sample/appsettings.Development.json

@@ -0,0 +1,8 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  }
+}

+ 9 - 0
src/OpenApi/sample/appsettings.json

@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*"
+}

+ 116 - 0
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
+{
+    /// <summary>
+    /// Maps the HTTP method of the ApiDescription to the OpenAPI <see cref="OperationType"/> .
+    /// </summary>
+    /// <param name="apiDescription">The ApiDescription to resolve an operation type from.</param>
+    /// <returns>The <see cref="OperationType"/> associated with the given <paramref name="apiDescription"/>.</returns>
+    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}"),
+        };
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    /// <param name="apiDescription">The ApiDescription to resolve an item path from.</param>
+    /// <returns>The resolved item path for the given <paramref name="apiDescription"/>.</returns>
+    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();
+    }
+
+    /// <summary>
+    /// Determines if the given <see cref="ApiParameterDescription" /> is a request body parameter.
+    /// </summary>
+    /// <param name="apiParameterDescription">The <see cref="ApiParameterDescription"/> to check. </param>
+    /// <returns>Returns <langword ref="true"/> if the given parameter comes from the request body, <langword ref="false"/> otherwise.</returns>
+    public static bool IsRequestBodyParameter(this ApiParameterDescription apiParameterDescription) =>
+        apiParameterDescription.Source == BindingSource.Body ||
+        apiParameterDescription.Source == BindingSource.FormFile ||
+        apiParameterDescription.Source == BindingSource.Form;
+
+    /// <summary>
+    /// Retrieves the form parameters from the ApiDescription, if they exist.
+    /// </summary>
+    /// <param name="apiDescription">The ApiDescription to resolve form parameters from.</param>
+    /// <param name="formParameters">A list of <see cref="ApiParameterDescription"/> associated with the form parameters.</param>
+    /// <returns><see langword="true"/> if form parameters were found, <see langword="false"/> otherwise.</returns>
+    public static bool TryGetFormParameters(this ApiDescription apiDescription, out IEnumerable<ApiParameterDescription> formParameters)
+    {
+        formParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Form || parameter.Source == BindingSource.FormFile);
+        return formParameters.Any();
+    }
+
+    /// <summary>
+    /// Retrieves the body parameter from the ApiDescription, if it exists.
+    /// </summary>
+    /// <param name="apiDescription">The ApiDescription to resolve the body parameter from.</param>
+    /// <param name="bodyParameter">The <see cref="ApiParameterDescription"/> associated with the body parameter.</param>
+    /// <returns><see langword="true"/> if a single body parameter was found, <see langword="false"/> otherwise.</returns>
+    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;
+    }
+}

+ 0 - 0
src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs → src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs


+ 65 - 0
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;
+
+/// <summary>
+/// OpenAPI-related methods for <see cref="IEndpointRouteBuilder"/>.
+/// </summary>
+public static class OpenApiEndpointRouteBuilderExtensions
+{
+    /// <summary>
+    /// Register an endpoint onto the current application for resolving the OpenAPI document associated
+    /// with the current application.
+    /// </summary>
+    /// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/>.</param>
+    /// <param name="pattern">The route to register the endpoint on. Must include the 'documentName' route parameter.</param>
+    /// <returns>An <see cref="IEndpointRouteBuilder"/> that can be used to further customize the endpoint.</returns>
+    public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = OpenApiConstants.DefaultOpenApiRoute)
+    {
+        var options = endpoints.ServiceProvider.GetRequiredService<IOptionsMonitor<OpenApiOptions>>();
+        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<OpenApiDocumentService>(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();
+    }
+}

+ 72 - 0
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;
+
+/// <summary>
+/// OpenAPI-related methods for <see cref="IServiceCollection"/>.
+/// </summary>
+public static class OpenApiServiceCollectionExtensions
+{
+    /// <summary>
+    /// Adds OpenAPI services related to the given document name to the specified <see cref="IServiceCollection"/>.
+    /// </summary>
+    /// <param name="services">The <see cref="IServiceCollection"/> to register services onto.</param>
+    /// <param name="documentName">The name of the OpenAPI document associated with registered services.</param>
+    public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName)
+    {
+        ArgumentNullException.ThrowIfNull(services);
+
+        return services.AddOpenApi(documentName, _ => { });
+    }
+
+    /// <summary>
+    /// Adds OpenAPI services related to the given document name to the specified <see cref="IServiceCollection"/> with the specified options.
+    /// </summary>
+    /// <param name="services">The <see cref="IServiceCollection"/> to register services onto.</param>
+    /// <param name="documentName">The name of the OpenAPI document associated with registered services.</param>
+    /// <param name="configureOptions">A delegate used to configure the target <see cref="OpenApiOptions"/>.</param>
+    public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action<OpenApiOptions> configureOptions)
+    {
+        ArgumentNullException.ThrowIfNull(services);
+        ArgumentNullException.ThrowIfNull(configureOptions);
+
+        services.AddOpenApiCore(documentName);
+        services.Configure<OpenApiOptions>(documentName, options =>
+        {
+            options.DocumentName = documentName;
+            configureOptions(options);
+        });
+        return services;
+    }
+
+    /// <summary>
+    /// Adds OpenAPI services related to the default document to the specified <see cref="IServiceCollection"/> with the specified options.
+    /// </summary>
+    /// <param name="services">The <see cref="IServiceCollection"/> to register services onto.</param>
+    /// <param name="configureOptions">A delegate used to configure the target <see cref="OpenApiOptions"/>.</param>
+    public static IServiceCollection AddOpenApi(this IServiceCollection services, Action<OpenApiOptions> configureOptions)
+            => services.AddOpenApi(OpenApiConstants.DefaultDocumentName, configureOptions);
+
+    /// <summary>
+    /// Adds OpenAPI services related to the default document to the specified <see cref="IServiceCollection"/>.
+    /// </summary>
+    /// <param name="services">The <see cref="IServiceCollection"/> to register services onto.</param>
+    public static IServiceCollection AddOpenApi(this IServiceCollection services)
+        => services.AddOpenApi(OpenApiConstants.DefaultDocumentName);
+
+    private static IServiceCollection AddOpenApiCore(this IServiceCollection services, string documentName)
+    {
+        services.AddEndpointsApiExplorer();
+        services.AddKeyedSingleton<OpenApiComponentService>(documentName);
+        services.AddKeyedSingleton<OpenApiDocumentService>(documentName);
+        // Required for build-time generation
+        services.AddSingleton<IDocumentProvider, OpenApiDocumentProvider>();
+        // Required to resolve document names for build-time generation
+        services.AddSingleton(new NamedService<OpenApiDocumentService>(documentName));
+        return services;
+    }
+}

+ 34 - 0
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;
+
+/// <summary>
+/// This comparer is used to maintain a globally unique list of tags encountered
+/// in a particular OpenAPI document.
+/// </summary>
+internal class OpenApiTagComparer : IEqualityComparer<OpenApiTag>
+{
+    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();
+}

+ 11 - 1
src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj

@@ -4,14 +4,18 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <PackageTags>aspnetcore;openapi</PackageTags>
+    <!-- Needed to support compiling Utf8BufferTextWriter implementation shared with SignalR -->
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     <Description>Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations.</Description>
   </PropertyGroup>
 
   <ItemGroup>
     <Reference Include="Microsoft.OpenApi" />
     <Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
+    <Reference Include="Microsoft.AspNetCore.Http.Results" />
     <Reference Include="Microsoft.AspNetCore.Routing" />
     <Reference Include="Microsoft.AspNetCore.Mvc.Core" />
+    <Reference Include="Microsoft.AspNetCore.Mvc.ApiExplorer" />
   </ItemGroup>
 
   <ItemGroup>
@@ -24,6 +28,12 @@
 
   <ItemGroup>
     <InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Tests" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Microbenchmarks" />
   </ItemGroup>
 
-</Project>
+  <ItemGroup>
+    <Compile Include="$(RepoRoot)/src/SignalR/common/Shared/Utf8BufferTextWriter.cs" LinkBase="Shared" />
+    <Compile Include="$(RepoRoot)/src/SignalR/common/Shared/MemoryBufferWriter.cs" LinkBase="Shared" />
+  </ItemGroup>
+
+</Project>

+ 36 - 0
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.Mvc.ApiExplorer.ApiDescription!, bool>!
+Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.set -> void
+Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseOperationTransformer(System.Func<Microsoft.OpenApi.Models.OpenApiOperation!, Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! 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<Microsoft.OpenApi.Models.OpenApiDocument!, Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions!
+Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer<TTransformerType>() -> 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<Microsoft.AspNetCore.OpenApi.OpenApiOptions!>! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.OpenApi.OpenApiOptions!>! 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.Mvc.ApiExplorer.ApiDescriptionGroup!>!
+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

+ 23 - 0
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;
+
+/// <summary>
+/// Represents a provider for OpenAPI documents to support build-time generation.
+/// </summary>
+/// <remarks>
+/// 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.
+/// </remarks>
+internal interface IDocumentProvider
+{
+    IEnumerable<string> GetDocumentNames();
+    Task GenerateAsync(string documentName, TextWriter writer);
+}

+ 18 - 0
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;
+
+/// <summary>
+/// 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.
+/// </summary>
+internal sealed class NamedService<TService>(string name)
+{
+    public string Name { get; } = name;
+}

+ 38 - 0
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;
+
+/// <summary>
+/// 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.
+/// </summary>
+internal sealed class OpenApiComponentService
+{
+    private readonly ConcurrentDictionary<Type, OpenApiSchema> _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" };
+    }
+}

+ 13 - 0
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";
+}

+ 46 - 0
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
+{
+    /// <summary>
+    /// Serializes the OpenAPI document associated with a given document name to
+    /// the provided writer.
+    /// </summary>
+    /// <param name="documentName">The name of the document to resolve.</param>
+    /// <param name="writer">A text writer associated with the document to write to.</param>
+    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<OpenApiDocumentService>(documentName);
+        var options = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenApiOptions>>();
+        var namedOption = options.Get(documentName);
+        var document = await targetDocumentService.GetOpenApiDocumentAsync();
+        var jsonWriter = new OpenApiJsonWriter(writer);
+        document.Serialize(jsonWriter, namedOption.OpenApiVersion);
+    }
+
+    /// <summary>
+    /// Provides all document names that are currently managed in the application.
+    /// </summary>
+    public IEnumerable<string> 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<NamedService<OpenApiDocumentService>>();
+        return documentServices.Select(docService => docService.Name);
+    }
+}

+ 337 - 0
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<OpenApiOptions> optionsMonitor,
+    IServiceProvider serviceProvider)
+{
+    private readonly OpenApiOptions _options = optionsMonitor.Get(documentName);
+    private readonly OpenApiComponentService _componentService = serviceProvider.GetRequiredKeyedService<OpenApiComponentService>(documentName);
+
+    private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true };
+
+    /// <summary>
+    /// Cache of <see cref="OpenApiOperationTransformerContext"/> 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.
+    /// </summary>
+    private readonly ConcurrentDictionary<string, OpenApiOperationTransformerContext> _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<OpenApiDocument> 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<OpenApiTag> 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
+        };
+    }
+
+    /// <summary>
+    /// Gets the OpenApiPaths for the document based on the ApiDescriptions.
+    /// </summary>
+    /// <remarks>
+    /// 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.
+    /// </remarks>
+    private OpenApiPaths GetOpenApiPaths(HashSet<OpenApiTag> 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<OperationType, OpenApiOperation> GetOperations(IGrouping<string?, ApiDescription> descriptions, HashSet<OpenApiTag> capturedTags)
+    {
+        var operations = new Dictionary<OperationType, OpenApiOperation>();
+        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<OpenApiTag> 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<IEndpointSummaryMetadata>().LastOrDefault()?.Summary;
+
+    private static string? GetDescription(ApiDescription description)
+        => description.ActionDescriptor.EndpointMetadata.OfType<IEndpointDescriptionMetadata>().LastOrDefault()?.Description;
+
+    private static List<OpenApiTag>? GetTags(ApiDescription description)
+    {
+        var actionDescriptor = description.ActionDescriptor;
+        if (actionDescriptor.EndpointMetadata?.OfType<ITagsMetadata>().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<string, OpenApiMediaType>()
+        };
+
+        // 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<ProducesAttribute>()
+            .SelectMany(attr => attr.ContentTypes);
+        foreach (var contentType in explicitContentTypes)
+        {
+            response.Content[contentType] = new OpenApiMediaType();
+        }
+
+        return response;
+    }
+
+    private static List<OpenApiParameter>? GetParameters(ApiDescription description)
+    {
+        List<OpenApiParameter>? 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<ApiRequestFormat> supportedRequestFormats, IEnumerable<ApiParameterDescription> 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<string, OpenApiMediaType>()
+        };
+
+        // Forms are represented as objects with properties for each form field.
+        var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
+        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<string, OpenApiEncoding>() { [contentType] = _defaultFormEncoding }
+            };
+        }
+
+        return requestBody;
+    }
+
+    private static OpenApiRequestBody GetJsonRequestBody(IList<ApiRequestFormat> supportedRequestFormats, ApiParameterDescription bodyParameter)
+    {
+        if (supportedRequestFormats.Count == 0)
+        {
+            supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }];
+        }
+
+        var requestBody = new OpenApiRequestBody
+        {
+            Required = bodyParameter.IsRequired,
+            Content = new Dictionary<string, OpenApiMediaType>()
+        };
+
+        foreach (var requestForm in supportedRequestFormats)
+        {
+            var contentType = requestForm.MediaType;
+            requestBody.Content[contentType] = new OpenApiMediaType();
+        }
+
+        return requestBody;
+    }
+}

+ 0 - 0
src/OpenApi/src/OpenApiGenerator.cs → src/OpenApi/src/Services/OpenApiGenerator.cs


+ 92 - 0
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;
+
+/// <summary>
+/// Options to support the construction of OpenAPI documents.
+/// </summary>
+public sealed class OpenApiOptions
+{
+    internal readonly List<IOpenApiDocumentTransformer> DocumentTransformers = [];
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="OpenApiOptions"/> class
+    /// with the default <see cref="ShouldInclude"/> predicate.
+    /// </summary>
+    public OpenApiOptions()
+    {
+        ShouldInclude = (description) => description.GroupName == null || description.GroupName == DocumentName;
+    }
+
+    /// <summary>
+    /// The version of the OpenAPI specification to use. Defaults to <see cref="OpenApiSpecVersion.OpenApi3_0"/>.
+    /// </summary>
+    public OpenApiSpecVersion OpenApiVersion { get; set; } = OpenApiSpecVersion.OpenApi3_0;
+
+    /// <summary>
+    /// The name of the OpenAPI document this <see cref="OpenApiOptions"/> instance is associated with.
+    /// </summary>
+    public string DocumentName { get; internal set; } = OpenApiConstants.DefaultDocumentName;
+
+    /// <summary>
+    /// A delegate to determine whether a given <see cref="ApiDescription"/> should be included in the given OpenAPI document.
+    /// </summary>
+    public Func<ApiDescription, bool> ShouldInclude { get; set; }
+
+    /// <summary>
+    /// Registers a new document transformer on the current <see cref="OpenApiOptions"/> instance.
+    /// </summary>
+    /// <typeparam name="TTransformerType">The type of the <see cref="IOpenApiDocumentTransformer"/> to instantiate.</typeparam>
+    /// <returns>The <see cref="OpenApiOptions"/> instance for further customization.</returns>
+    public OpenApiOptions UseTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>()
+        where TTransformerType : IOpenApiDocumentTransformer
+    {
+        DocumentTransformers.Add(new TypeBasedOpenApiDocumentTransformer(typeof(TTransformerType)));
+        return this;
+    }
+
+    /// <summary>
+    /// Registers a given instance of <see cref="IOpenApiDocumentTransformer"/> on the current <see cref="OpenApiOptions"/> instance.
+    /// </summary>
+    /// <param name="transformer">The <see cref="IOpenApiDocumentTransformer"/> instance to use.</param>
+    /// <returns>The <see cref="OpenApiOptions"/> instance for further customization.</returns>
+    public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer)
+    {
+        ArgumentNullException.ThrowIfNull(transformer, nameof(transformer));
+
+        DocumentTransformers.Add(transformer);
+        return this;
+    }
+
+    /// <summary>
+    /// Registers a given delegate as a document transformer on the current <see cref="OpenApiOptions"/> instance.
+    /// </summary>
+    /// <param name="transformer">The delegate representing the document transformer.</param>
+    /// <returns>The <see cref="OpenApiOptions"/> instance for further customization.</returns>
+    public OpenApiOptions UseTransformer(Func<OpenApiDocument, OpenApiDocumentTransformerContext, CancellationToken, Task> transformer)
+    {
+        ArgumentNullException.ThrowIfNull(transformer, nameof(transformer));
+
+        DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer));
+        return this;
+    }
+
+    /// <summary>
+    /// Registers a given delegate as an operation transformer on the current <see cref="OpenApiOptions"/> instance.
+    /// </summary>
+    /// <param name="transformer">The delegate representing the operation transformer.</param>
+    /// <returns>The <see cref="OpenApiOptions"/> instance for further customization.</returns>
+    public OpenApiOptions UseOperationTransformer(Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task> transformer)
+    {
+        ArgumentNullException.ThrowIfNull(transformer, nameof(transformer));
+
+        DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer));
+        return this;
+    }
+}

+ 79 - 0
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<OpenApiDocument, OpenApiDocumentTransformerContext, CancellationToken, Task>? _documentTransformer;
+    private readonly Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task>? _operationTransformer;
+
+    public DelegateOpenApiDocumentTransformer(Func<OpenApiDocument, OpenApiDocumentTransformerContext, CancellationToken, Task> transformer)
+    {
+        _documentTransformer = transformer;
+    }
+
+    public DelegateOpenApiDocumentTransformer(Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task> 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<OpenApiDocumentService>(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.");
+                    }
+                }
+            }
+        }
+    }
+}

+ 21 - 0
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;
+
+/// <summary>
+/// Represents a transformer that can be used to modify an OpenAPI document.
+/// </summary>
+public interface IOpenApiDocumentTransformer
+{
+    /// <summary>
+    /// Transforms the specified OpenAPI document.
+    /// </summary>
+    /// <param name="document">The <see cref="OpenApiDocument"/> to modify.</param>
+    /// <param name="context">The <see cref="OpenApiDocumentTransformerContext"/> associated with the <see paramref="document"/>.</param>
+    /// <param name="cancellationToken">The cancellation token to use.</param>
+    /// <returns>The task object representing the asynchronous operation.</returns>
+    public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken);
+}

+ 27 - 0
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;
+
+/// <summary>
+/// Represents the context in which an OpenAPI document transformer is executed.
+/// </summary>
+public sealed class OpenApiDocumentTransformerContext
+{
+    /// <summary>
+    /// Gets the name of the associated OpenAPI document.
+    /// </summary>
+    public required string DocumentName { get; init; }
+
+    /// <summary>
+    /// Gets the API description groups associated with current document.
+    /// </summary>
+    public required IReadOnlyList<ApiDescriptionGroup> DescriptionGroups { get; init; }
+
+    /// <summary>
+    /// Gets the application services associated with current document.
+    /// </summary>
+    public required IServiceProvider ApplicationServices { get; init; }
+}

+ 27 - 0
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;
+
+/// <summary>
+/// Represents the context in which an OpenAPI operation transformer is executed.
+/// </summary>
+public sealed class OpenApiOperationTransformerContext
+{
+    /// <summary>
+    /// Gets the name of the associated OpenAPI document.
+    /// </summary>
+    public required string DocumentName { get; init; }
+
+    /// <summary>
+    /// Gets the API description associated with target operation.
+    /// </summary>
+    public required ApiDescription Description { get; init; }
+
+    /// <summary>
+    /// Gets the application services associated with the current document the target operation is in.
+    /// </summary>
+    public required IServiceProvider ApplicationServices { get; init; }
+}

+ 34 - 0
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();
+            }
+        }
+    }
+}

+ 74 - 0
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<InvalidOperationException>(() => apiDescription.GetOperationType());
+        Assert.Equal($"Unsupported HTTP method: {methodName}", exception.Message);
+    }
+}

+ 171 - 0
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<IEndpointConventionBuilder>(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<RouteEndpoint>(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<OpenApiDocument> 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<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddSingleton(CreateApiDescriptionGroupCollectionProvider())
+            .AddOpenApi(documentName)
+            .BuildServiceProvider();
+        return serviceProvider;
+    }
+}

+ 0 - 0
src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs → src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs


+ 192 - 0
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<IServiceCollection>(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<IOptionsSnapshot<OpenApiOptions>>();
+        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<IServiceCollection>(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<IOptionsSnapshot<OpenApiOptions>>();
+        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<IServiceCollection>(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<IOptionsSnapshot<OpenApiOptions>>();
+        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<IServiceCollection>(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<IOptionsSnapshot<OpenApiOptions>>();
+        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<IOptionsSnapshot<OpenApiOptions>>();
+        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<IOptionsSnapshot<OpenApiOptions>>();
+        var namedOption = options.Get(documentName);
+        Assert.Equal(documentName, namedOption.DocumentName);
+        Assert.Equal(OpenApiSpecVersion.OpenApi2_0, namedOption.OpenApiVersion);
+    }
+}

+ 5 - 0
src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj

@@ -10,6 +10,7 @@
     <Reference Include="Microsoft.AspNetCore.Http.Results" />
     <Reference Include="Microsoft.Extensions.DependencyInjection" />
     <Reference Include="Microsoft.OpenApi" />
+    <Reference Include="Microsoft.OpenApi.Readers" />
     <Reference Include="Microsoft.AspNetCore.Routing" />
     <Reference Include="Microsoft.Extensions.Hosting" />
     <Reference Include="Microsoft.AspNetCore.Mvc.Core" />
@@ -17,4 +18,8 @@
     <Reference Include="Microsoft.AspNetCore.OpenApi" />
   </ItemGroup>
 
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Microbenchmarks" />
+  </ItemGroup>
+
 </Project>

+ 74 - 0
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<OpenApiDocument> 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<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddSingleton(CreateApiDescriptionGroupCollectionProvider());
+        foreach (var documentName in documentNames)
+        {
+            serviceCollection.AddOpenApi(documentName);
+        }
+        var serviceProvider = serviceCollection.BuildServiceProvider();
+        return serviceProvider;
+    }
+}

+ 56 - 0
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<IApiDescriptionGroupCollectionProvider>().Object,
+            hostEnvironment,
+            new Mock<IOptionsMonitor<OpenApiOptions>>().Object,
+            new Mock<IKeyedServiceProvider>().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<IApiDescriptionGroupCollectionProvider>().Object,
+            hostEnvironment,
+            new Mock<IOptionsMonitor<OpenApiOptions>>().Object,
+            new Mock<IKeyedServiceProvider>().Object);
+
+        // Act
+        var info = docService.GetOpenApiInfo();
+
+        // Assert
+        Assert.Equal("TestApplication | v2", info.Title);
+    }
+}

+ 181 - 0
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);
+            });
+        });
+    }
+}

+ 138 - 0
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);
+        });
+    }
+}

+ 171 - 0
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);
+                        });
+                }
+            );
+        });
+    }
+}

+ 391 - 0
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);
+        });
+    }
+
+}

+ 257 - 0
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.
+        });
+    }
+}

+ 139 - 0
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<OpenApiDocument> verifyOpenApiDocument)
+        => await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument);
+
+    public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions, Action<OpenApiDocument> 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<EndpointDataSource>().Single();
+        var hostEnvironment = new HostEnvironment
+        {
+            ApplicationName = nameof(OpenApiDocumentServiceTests)
+        };
+        var options = new Mock<IOptionsMonitor<OpenApiOptions>>();
+        options.Setup(o => o.Get(It.IsAny<string>())).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<ApiDescription> apiDescriptions = null)
+    {
+        var apiDescriptionGroup = new ApiDescriptionGroup("testGroupName", (apiDescriptions ?? Array.Empty<ApiDescription>()).AsReadOnly());
+        var apiDescriptionGroupCollection = new ApiDescriptionGroupCollection([apiDescriptionGroup], 1);
+        var apiDescriptionGroupCollectionProvider = new Mock<IApiDescriptionGroupCollectionProvider>();
+        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<EndpointDataSource>();
+        }
+
+        public IApplicationBuilder ApplicationBuilder { get; }
+
+        public IApplicationBuilder CreateApplicationBuilder() => ApplicationBuilder.New();
+
+        public ICollection<EndpointDataSource> 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<RouteHandlerOptions>))
+            {
+                return Options.Create(new RouteHandlerOptions());
+            }
+
+            return _serviceProvider.GetService(serviceType);
+        }
+    }
+}

+ 0 - 0
src/OpenApi/test/OpenApiGeneratorTests.cs → src/OpenApi/test/Services/OpenApiGeneratorTests.cs


+ 11 - 0
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);

+ 230 - 0
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<ActivatedTransformer>();
+
+        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<Dependency>();
+        var builder = CreateBuilder(serviceCollection);
+
+        builder.MapGet("/todo", () => { });
+
+        var options = new OpenApiOptions();
+        options.UseTransformer<ActivatedTransformerWithDependency>();
+
+        // 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<Dependency>();
+        var builder = CreateBuilder(serviceCollection);
+
+        builder.MapGet("/todo", () => { });
+
+        var options = new OpenApiOptions();
+        options.UseTransformer<ActivatedTransformerWithDependency>();
+
+        // 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>();
+
+        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>();
+
+        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;
+    }
+}

+ 87 - 0
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<OpenApiDocument, OpenApiDocumentTransformerContext, CancellationToken, Task>((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<DelegateOpenApiDocumentTransformer>(insertedTransformer);
+        Assert.IsType<OpenApiOptions>(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<OpenApiOptions>(result);
+    }
+
+    [Fact]
+    public void UseTransformer_WithDocumentTransformerType()
+    {
+        // Arrange
+        var options = new OpenApiOptions();
+
+        // Act
+        var result = options.UseTransformer<TestOpenApiDocumentTransformer>();
+
+        // Assert
+        var insertedTransformer = Assert.Single(options.DocumentTransformers);
+        Assert.IsType<TypeBasedOpenApiDocumentTransformer>(insertedTransformer);
+        Assert.IsType<OpenApiOptions>(result);
+    }
+
+    [Fact]
+    public void UseTransformer_WithOperationTransformerDelegate()
+    {
+        // Arrange
+        var options = new OpenApiOptions();
+        var transformer = new Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task>((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<DelegateOpenApiDocumentTransformer>(insertedTransformer);
+        Assert.IsType<OpenApiOptions>(result);
+    }
+
+    private class TestOpenApiDocumentTransformer : IOpenApiDocumentTransformer
+    {
+        public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
+        {
+            return Task.CompletedTask;
+        }
+    }
+}

+ 148 - 0
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<InvalidOperationException>(() => 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);
+    }
+}