diff --git a/AspNetCore.sln b/AspNetCore.sln
index 68269bb213ca..4b0b6d8c126e 100644
--- a/AspNetCore.sln
+++ b/AspNetCore.sln
@@ -1788,6 +1788,8 @@ 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
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -10789,6 +10791,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
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -11672,6 +11690,7 @@ 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}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index a987f75730aa..2539beee1c9b 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -69,6 +69,7 @@ and are generated based on the last package release.
     <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" />
diff --git a/eng/Versions.props b/eng/Versions.props
index 682df7e25cb8..89ad56b98ac4 100644
--- a/eng/Versions.props
+++ b/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>
diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf
index 0311c6b7ddcd..ca74ac85ba10 100644
--- a/src/OpenApi/OpenApi.slnf
+++ b/src/OpenApi/OpenApi.slnf
@@ -8,7 +8,8 @@
       "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"
     ]
   }
-}
\ No newline at end of file
+}
diff --git a/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs
new file mode 100644
index 000000000000..fd196d7fc101
--- /dev/null
+++ b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+internal static class OpenApiEndpointRouteBuilderExtensions
+{
+    /// <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();
+    }
+}
diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs
new file mode 100644
index 000000000000..77d723969cf6
--- /dev/null
+++ b/src/OpenApi/sample/Program.cs
@@ -0,0 +1,32 @@
+// 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;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddOpenApi("v1");
+builder.Services.AddOpenApi("v2");
+
+var app = builder.Build();
+
+app.MapOpenApi();
+if (app.Environment.IsDevelopment())
+{
+    app.MapSwaggerUi();
+}
+
+var v1 = app.MapGroup("v1")
+    .WithMetadata(new ApiExplorerSettingsAttribute { GroupName = "v1" });
+
+var v2 = app.MapGroup("v2")
+    .WithMetadata(new ApiExplorerSettingsAttribute { GroupName = "v2" });
+
+v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo));
+v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now));
+
+app.Run();
+
+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);
diff --git a/src/OpenApi/sample/Properties/launchSettings.json b/src/OpenApi/sample/Properties/launchSettings.json
new file mode 100644
index 000000000000..e7c91524954d
--- /dev/null
+++ b/src/OpenApi/sample/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:43164",
+      "sslPort": 44391
+    }
+  },
+  "profiles": {
+    "http": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "applicationUrl": "http://localhost:5051",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "https": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "applicationUrl": "https://localhost:7174;http://localhost:5051",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}
diff --git a/src/OpenApi/sample/Sample.csproj b/src/OpenApi/sample/Sample.csproj
new file mode 100644
index 000000000000..1cbc9b0ff713
--- /dev/null
+++ b/src/OpenApi/sample/Sample.csproj
@@ -0,0 +1,20 @@
+<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.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>
+
+</Project>
diff --git a/src/OpenApi/sample/appsettings.Development.json b/src/OpenApi/sample/appsettings.Development.json
new file mode 100644
index 000000000000..0c208ae9181e
--- /dev/null
+++ b/src/OpenApi/sample/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  }
+}
diff --git a/src/OpenApi/sample/appsettings.json b/src/OpenApi/sample/appsettings.json
new file mode 100644
index 000000000000..10f68b8c8b4f
--- /dev/null
+++ b/src/OpenApi/sample/appsettings.json
@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*"
+}
diff --git a/src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs
similarity index 100%
rename from src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs
rename to src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs
diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs
new file mode 100644
index 000000000000..052bc13a2554
--- /dev/null
+++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs
@@ -0,0 +1,65 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Extensions;
+using Microsoft.OpenApi.Writers;
+
+namespace Microsoft.AspNetCore.Builder;
+
+/// <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();
+                    var documentOptions = options.Get(documentName);
+                    using var output = MemoryBufferWriter.Get();
+                    using var writer = Utf8BufferTextWriter.Get(output);
+                    try
+                    {
+                        document.Serialize(new OpenApiJsonWriter(writer), documentOptions.OpenApiVersion);
+                        await context.Response.BodyWriter.WriteAsync(output.ToArray());
+                        await context.Response.BodyWriter.FlushAsync();
+                    }
+                    finally
+                    {
+                        MemoryBufferWriter.Return(output);
+                        Utf8BufferTextWriter.Return(writer);
+                    }
+
+                }
+            }).ExcludeFromDescription();
+    }
+}
diff --git a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs
new file mode 100644
index 000000000000..b7372551a0ac
--- /dev/null
+++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.Extensions.ApiDescriptions;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// <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;
+    }
+}
diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
index 34a81cb70466..ac447a7cc31d 100644
--- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
+++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
@@ -4,14 +4,17 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <PackageTags>aspnetcore;openapi</PackageTags>
+    <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>
@@ -26,4 +29,9 @@
     <InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Tests" />
   </ItemGroup>
 
-</Project>
\ No newline at end of file
+  <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>
diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..76a7e128784b 100644
--- a/src/OpenApi/src/PublicAPI.Unshipped.txt
+++ b/src/OpenApi/src/PublicAPI.Unshipped.txt
@@ -1 +1,15 @@
 #nullable enable
+Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions
+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.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!
diff --git a/src/OpenApi/src/Services/IDocumentProvider.cs b/src/OpenApi/src/Services/IDocumentProvider.cs
new file mode 100644
index 000000000000..61ef9dc560fe
--- /dev/null
+++ b/src/OpenApi/src/Services/IDocumentProvider.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.ApiDescriptions;
+
+/// <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);
+}
diff --git a/src/OpenApi/src/Services/NamedService.cs b/src/OpenApi/src/Services/NamedService.cs
new file mode 100644
index 000000000000..ca1545313a76
--- /dev/null
+++ b/src/OpenApi/src/Services/NamedService.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+/// <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;
+}
diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs
new file mode 100644
index 000000000000..f327ee1c34b3
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiComponentService.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;
+
+/// <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
+{
+}
diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs
new file mode 100644
index 000000000000..801493beb2fb
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiConstants.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.
+
+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";
+}
diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs
new file mode 100644
index 000000000000..831475f8960a
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.OpenApi.Writers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.Extensions.Options;
+using System.Linq;
+using Microsoft.OpenApi.Extensions;
+
+namespace Microsoft.Extensions.ApiDescriptions;
+
+internal sealed class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocumentProvider
+{
+    /// <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);
+    }
+}
diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs
new file mode 100644
index 000000000000..6bab28a32110
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiDocumentService.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.
+
+using Microsoft.Extensions.Hosting;
+using Microsoft.OpenApi.Models;
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+internal sealed class OpenApiDocumentService(IHostEnvironment hostEnvironment)
+{
+    public Task<OpenApiDocument> GetOpenApiDocumentAsync()
+    {
+        var document = new OpenApiDocument
+        {
+            Info = new OpenApiInfo
+            {
+                Title = hostEnvironment.ApplicationName,
+                Version = OpenApiConstants.DefaultOpenApiVersion
+            }
+        };
+        return Task.FromResult(document);
+    }
+}
diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs
similarity index 100%
rename from src/OpenApi/src/OpenApiGenerator.cs
rename to src/OpenApi/src/Services/OpenApiGenerator.cs
diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs
new file mode 100644
index 000000000000..efe92b30dfb5
--- /dev/null
+++ b/src/OpenApi/src/Services/OpenApiOptions.cs
@@ -0,0 +1,37 @@
+// 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;
+
+namespace Microsoft.AspNetCore.OpenApi;
+
+/// <summary>
+/// Options to support the construction of OpenAPI documents.
+/// </summary>
+public sealed class OpenApiOptions
+{
+    /// <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; }
+}
diff --git a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs
new file mode 100644
index 000000000000..7dbb615e311a
--- /dev/null
+++ b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs
@@ -0,0 +1,189 @@
+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
+{
+    [Fact]
+    public void MapOpenApi_ReturnsEndpointConventionBuilder()
+    {
+        // Arrange
+        var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+        var serviceProviderIsService = new ServiceProviderIsService();
+        var serviceProvider = new ServiceCollection()
+            .AddSingleton<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddOpenApi()
+            .BuildServiceProvider();
+        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 hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+        var serviceProviderIsService = new ServiceProviderIsService();
+        var serviceProvider = new ServiceCollection()
+            .AddSingleton<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddOpenApi()
+            .BuildServiceProvider();
+        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 hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+        var serviceProviderIsService = new ServiceProviderIsService();
+        var serviceProvider = new ServiceCollection()
+            .AddSingleton<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddOpenApi()
+            .BuildServiceProvider();
+        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", document.Info.Title);
+            Assert.Equal("1.0.0", document.Info.Version);
+        });
+    }
+
+    [Fact]
+    public async Task MapOpenApi_ReturnsDefaultDocumentIfNoNameProvided()
+    {
+        // Arrange
+        var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+        var serviceProviderIsService = new ServiceProviderIsService();
+        var serviceProvider = new ServiceCollection()
+            .AddSingleton<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddOpenApi()
+            .BuildServiceProvider();
+        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", document.Info.Title);
+            Assert.Equal("1.0.0", document.Info.Version);
+        });
+    }
+
+    [Fact]
+    public async Task MapOpenApi_Returns404ForUnresolvedDocument()
+    {
+        // Arrange
+        var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) };
+        var serviceProviderIsService = new ServiceProviderIsService();
+        var serviceProvider = new ServiceCollection()
+            .AddSingleton<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddOpenApi()
+            .BuildServiceProvider();
+        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 = new ServiceCollection()
+            .AddSingleton<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddOpenApi(documentName)
+            .BuildServiceProvider();
+        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", 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);
+    }
+}
diff --git a/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs b/src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs
similarity index 100%
rename from src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs
rename to src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs
diff --git a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs
new file mode 100644
index 000000000000..f297c1c3677c
--- /dev/null
+++ b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs
@@ -0,0 +1,189 @@
+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);
+    }
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
index 507534ae26c1..71abb72047ff 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
+++ b/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" />
diff --git a/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs b/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs
new file mode 100644
index 000000000000..f0eff7edee21
--- /dev/null
+++ b/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs
@@ -0,0 +1,73 @@
+using Microsoft.AspNetCore.OpenApi;
+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
+{
+    [Fact]
+    public async Task GenerateAsync_ReturnsDocument()
+    {
+        // Arrange
+        var documentName = "v1";
+        var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiDocumentProviderTests) };
+        var documentService = new OpenApiDocumentService(hostEnvironment);
+        var serviceCollection = new ServiceCollection();
+        serviceCollection
+            .AddOpenApi(documentName)
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddSingleton(documentService);
+        var serviceProvider = serviceCollection.BuildServiceProvider();
+        var documentProvider = new OpenApiDocumentProvider(serviceProvider);
+        var stringWriter = new StringWriter();
+
+        // Act
+        await documentProvider.GenerateAsync(documentName, stringWriter);
+
+        // Assert
+        ValidateOpenApiDocument(stringWriter, document =>
+        {
+            Assert.Equal(hostEnvironment.ApplicationName, document.Info.Title);
+            Assert.Equal("1.0.0", document.Info.Version);
+        });
+    }
+
+    [Fact]
+    public void GetDocumentNames_ReturnsAllRegisteredDocumentName()
+    {
+        // Arrange
+        var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiDocumentProviderTests) };
+        var documentService = new OpenApiDocumentService(hostEnvironment);
+        var serviceCollection = new ServiceCollection();
+        serviceCollection
+            .AddOpenApi("v2")
+            .AddOpenApi("internal")
+            .AddOpenApi("public")
+            .AddOpenApi("v1")
+            .AddSingleton<IHostEnvironment>(hostEnvironment)
+            .AddSingleton(documentService);
+        var serviceProvider = serviceCollection.BuildServiceProvider();
+        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);
+    }
+}
diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/Services/OpenApiGeneratorTests.cs
similarity index 100%
rename from src/OpenApi/test/OpenApiGeneratorTests.cs
rename to src/OpenApi/test/Services/OpenApiGeneratorTests.cs