From 12d1ef0871771c407e71ac433a8e41d58858da7f Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 25 Mar 2024 16:33:50 -0700 Subject: [PATCH 1/7] Add entry-point APIs for OpenAPI support --- AspNetCore.sln | 19 +++ eng/Dependencies.props | 1 + eng/Versions.props | 1 + src/OpenApi/OpenApi.slnf | 5 +- .../sample/EndpointRouteBuilderExtensions.cs | 46 ++++++ src/OpenApi/sample/Program.cs | 32 ++++ .../sample/Properties/launchSettings.json | 38 +++++ src/OpenApi/sample/Sample.csproj | 20 +++ .../sample/appsettings.Development.json | 8 + src/OpenApi/sample/appsettings.json | 9 ++ ...nApiEndpointConventionBuilderExtensions.cs | 0 .../OpenApiEndpointRouteBuilderExtensions.cs | 49 ++++++ .../OpenApiServiceCollectionExtensions.cs | 73 +++++++++ .../src/Microsoft.AspNetCore.OpenApi.csproj | 4 +- src/OpenApi/src/PublicAPI.Unshipped.txt | 14 ++ src/OpenApi/src/Services/IDocumentProvider.cs | 23 +++ src/OpenApi/src/Services/NamedService.cs | 16 ++ .../src/Services/OpenApiComponentService.cs | 11 ++ src/OpenApi/src/Services/OpenApiConstants.cs | 9 ++ .../src/Services/OpenApiDocumentProvider.cs | 43 ++++++ .../src/Services/OpenApiDocumentService.cs | 25 ++++ .../src/{ => Services}/OpenApiGenerator.cs | 0 src/OpenApi/src/Services/OpenApiOptions.cs | 37 +++++ ...nApiEndpointRouteBuilderExtensionsTests.cs | 96 ++++++++++++ ...penApiRouteHandlerBuilderExtensionTests.cs | 0 ...OpenApiServiceCollectionExtensionsTests.cs | 141 ++++++++++++++++++ .../Microsoft.AspNetCore.OpenApi.Tests.csproj | 1 + .../Services/OpenApiDocumentProviderTests.cs | 73 +++++++++ .../{ => Services}/OpenApiGeneratorTests.cs | 0 29 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 src/OpenApi/sample/EndpointRouteBuilderExtensions.cs create mode 100644 src/OpenApi/sample/Program.cs create mode 100644 src/OpenApi/sample/Properties/launchSettings.json create mode 100644 src/OpenApi/sample/Sample.csproj create mode 100644 src/OpenApi/sample/appsettings.Development.json create mode 100644 src/OpenApi/sample/appsettings.json rename src/OpenApi/src/{ => Extensions}/OpenApiEndpointConventionBuilderExtensions.cs (100%) create mode 100644 src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs create mode 100644 src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs create mode 100644 src/OpenApi/src/Services/IDocumentProvider.cs create mode 100644 src/OpenApi/src/Services/NamedService.cs create mode 100644 src/OpenApi/src/Services/OpenApiComponentService.cs create mode 100644 src/OpenApi/src/Services/OpenApiConstants.cs create mode 100644 src/OpenApi/src/Services/OpenApiDocumentProvider.cs create mode 100644 src/OpenApi/src/Services/OpenApiDocumentService.cs rename src/OpenApi/src/{ => Services}/OpenApiGenerator.cs (100%) create mode 100644 src/OpenApi/src/Services/OpenApiOptions.cs create mode 100644 src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs rename src/OpenApi/test/{ => Extensions}/OpenApiRouteHandlerBuilderExtensionTests.cs (100%) create mode 100644 src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs create mode 100644 src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs rename src/OpenApi/test/{ => Services}/OpenApiGeneratorTests.cs (100%) 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. + 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 @@ 4.0.5 6.0.0-preview.3.21167.1 1.6.13 + 1.6.13 6.0.322601 1.10.93 diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf index 0311c6b7ddcd..b592105d50fc 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 +{ + /// + /// Helper method to render Swagger UI view for testing. + /// + public static IEndpointConventionBuilder MapSwaggerUi(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/swagger/{documentName}", (string documentName) => Results.Content($$""" + + + + OpenAPI -- {{documentName}} + + + +
+ + + + + + + + """, "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 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + enable + + + + + + + + + + + + + + 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..0ec0c73b6a1a --- /dev/null +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -0,0 +1,49 @@ +// 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 System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Writers; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// OpenAPI-related methods for . +/// +public static class OpenApiEndpointRouteBuilderExtensions +{ + /// + /// Register an endpoint onto the current application for resolving the OpenAPI document associated + /// with the current application. + /// + /// The . + /// The route to register the endpoint on. Must include the 'documentName' route parameter. + /// An that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "/openapi/{documentName}.json") + { + var options = endpoints.ServiceProvider.GetRequiredService>(); + return endpoints.MapGet(pattern, async (string documentName, HttpContext context) => + { + // 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.GetRequiredKeyedService(documentName); + var document = await documentService.GetOpenApiDocumentAsync(); + var documentOptions = options.Get(documentName); + using var stringWriter = new StringWriter(CultureInfo.InvariantCulture); + var jsonWriter = new OpenApiJsonWriter(stringWriter); + document.Serialize(jsonWriter, documentOptions.OpenApiVersion); + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json;charset=utf-8"; + await context.Response.WriteAsync(stringWriter.ToString(), new UTF8Encoding(false)); + }).ExcludeFromDescription(); + } +} diff --git a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs new file mode 100644 index 000000000000..95cf6c994ba0 --- /dev/null +++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.ApiDescriptions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// OpenAPI-related methods for . +/// +public static class OpenApiServiceCollectionExtensions +{ + /// + /// Adds OpenAPI services related to the given document documentName to the specified . + /// + /// The to register services onto. + /// The documentName of the OpenAPI document associated with registered services. + public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOpenApiCore(documentName); + services.Configure(documentName, options => options.DocumentName = documentName); + return services; + } + + /// + /// Adds OpenAPI services related to the given document documentName to the specified with the specified options. + /// + /// The to register services onto. + /// The documentName of the OpenAPI document associated with registered services. + /// A function used to configure the target . + public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddOpenApiCore(documentName); + services.Configure(documentName, options => + { + options.DocumentName = documentName; + configureOptions(options); + }); + return services; + } + + /// + /// Adds OpenAPI services related to the default document to the specified with the specified options. + /// + /// The to register services onto. + /// A function used to configure the target . + public static IServiceCollection AddOpenApi(this IServiceCollection services, Action configureOptions) + => services.AddOpenApi(OpenApiConstants.DefaultDocumentName, configureOptions); + + /// + /// Adds OpenAPI services related to the default document to the specified . + /// + /// The to register services onto. + public static IServiceCollection AddOpenApi(this IServiceCollection services) + => services.AddOpenApi(OpenApiConstants.DefaultDocumentName); + + private static IServiceCollection AddOpenApiCore(this IServiceCollection services, string documentName) + { + services.AddEndpointsApiExplorer(); + services.AddKeyedSingleton(documentName); + services.AddKeyedSingleton(documentName); + // Required for build-time generation + services.AddSingleton(); + services.AddSingleton(new NamedService(documentName)); + return services; + } +} diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 34a81cb70466..5032c3d41761 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -10,8 +10,10 @@ + + @@ -26,4 +28,4 @@ - \ No newline at end of file + 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.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! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! 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..749772f14988 --- /dev/null +++ b/src/OpenApi/src/Services/IDocumentProvider.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ApiDescriptions; + +/// +/// Represents a provider for OpenAPI documents to support build-time generation. +/// +/// +/// The Microsoft.Extensions.ApiDescription.Server package and associated configuration +/// will execute the `dotnet getdocument` command at build-time to support build-time +/// generation of documents. The getdocument tool will launch the entry point assembly +/// and query it for service that implements the `IDocumentProvider` interface. For +/// historical reasons, the `IDocumentProvider` interface is not exposed publicly from +/// the framework and the `getdocument` tool will instead query for it using type name. +/// That means we must declare the `IDocumentProvider` interface under the namespace +/// that it expects. For more information, see https://github.com/dotnet/aspnetcore/blob/82c9b34d7206ba56ea1d641843e1f2fe6d2a0b1c/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs#L25. +/// +internal interface IDocumentProvider +{ + IEnumerable GetDocumentNames(); + Task GenerateAsync(string documentName, TextWriter writer); +} diff --git a/src/OpenApi/src/Services/NamedService.cs b/src/OpenApi/src/Services/NamedService.cs new file mode 100644 index 000000000000..b9064d7229bb --- /dev/null +++ b/src/OpenApi/src/Services/NamedService.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +// Keyed services don't provide an accessible API for resolving +// all the service keys associated with a given type. +// See https://github.com/dotnet/runtime/issues/100105 for more info. +// This internal class is used to track the document names that have been registered +// so that they can be resolved in the `IDocumentProvider` implementation. +// This is inspired by the implementation used in Orleans. See +// https://github.com/dotnet/orleans/blob/005ab200bc91302245857cb75efaa436296a1aae/src/Orleans.Runtime/Hosting/NamedService.cs. +internal class NamedService(string name) +{ + public string Name { get; } = name; +} diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs new file mode 100644 index 000000000000..787d44835d55 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiComponentService.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; + +/// +/// Supports managing elements that belong in the "components" section of +/// an OpenAPI document. In particular, this is the API that is used to +/// interact with the JSON schemas that are managed by a given OpenAPI document. +/// +internal class OpenApiComponentService { } diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs new file mode 100644 index 000000000000..54f2d8387c7d --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -0,0 +1,9 @@ +// 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"; +} diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs new file mode 100644 index 000000000000..8422aae1d7c5 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -0,0 +1,43 @@ +// 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 class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocumentProvider +{ + /// + /// Serializes the OpenAPI document associated with a given document name to + /// the provided writer. + /// + /// The name of the document to resolve. + /// A text writer associated with the document to write to. + public async Task GenerateAsync(string documentName, TextWriter writer) + { + var targetDocumentService = serviceProvider.GetRequiredKeyedService(documentName); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + var document = await targetDocumentService.GetOpenApiDocumentAsync(); + var jsonWriter = new OpenApiJsonWriter(writer); + document.Serialize(jsonWriter, namedOption.OpenApiVersion); + } + + /// + /// Provides all document names that are currently managed in the application. + /// + public IEnumerable GetDocumentNames() + { + // Keyed services don't provide an accessible API for resolving all of the + // registered keys, so we'll use the service provider to resolve an internal + // type we use to track the document names that have been registered. + // See https://github.com/dotnet/runtime/issues/100105 for more info. + var documentServices = serviceProvider.GetServices>(); + return documentServices.Select(docService => docService.Name); + } +} diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs new file mode 100644 index 000000000000..a18ef02f4d51 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal class OpenApiDocumentService(IHostEnvironment hostEnvironment) +{ + private readonly string _defaultOpenApiVersion = "1.0.0"; + + public Task GetOpenApiDocumentAsync() + { + var document = new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = hostEnvironment.ApplicationName, + Version = _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..94c2c29db204 --- /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; + +/// +/// Options to support the construction of OpenAPI documents. +/// +public class OpenApiOptions +{ + /// + /// Initializes a new instance of the class + /// with the default predicate. + /// + public OpenApiOptions() + { + ShouldInclude = (description) => description.GroupName == null || description.GroupName == DocumentName; + } + + /// + /// The version of the OpenAPI specification to use. Defaults to . + /// + public OpenApiSpecVersion OpenApiVersion { get; set; } = OpenApiSpecVersion.OpenApi3_0; + + /// + /// The name of the OpenAPI document this instance is associated with. + /// + public string DocumentName { get; internal set; } = OpenApiConstants.DefaultDocumentName; + + /// + /// A function to determine whether a given should be included in the given OpenAPI document. + /// + public Func 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..05b5f6a973b3 --- /dev/null +++ b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,96 @@ +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.AspNetCore.Http.HttpResults; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +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(serviceProviderIsService) + .AddSingleton(hostEnvironment) + .AddOpenApi() + .BuildServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + + // Act + var returnedBuilder = builder.MapOpenApi(); + + // Assert + Assert.IsAssignableFrom(returnedBuilder); + } + + [Fact] + public void MapOpenApi_SupportsCustomizingPath() + { + // Arrange + var expectedPath = "/custom/{documentName}/openapi.json"; + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = new ServiceCollection() + .AddSingleton(serviceProviderIsService) + .AddSingleton(hostEnvironment) + .AddOpenApi() + .BuildServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + + // Act + builder.MapOpenApi(expectedPath); + + // Assert + var generatedEndpoint = Assert.IsType(builder.DataSources.First().Endpoints.First()); + Assert.Equal(expectedPath, generatedEndpoint.RoutePattern.RawText); + } + + [Fact] + public async Task MapOpenApi_ReturnsRenderedDocument() + { + // Arrange + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = new ServiceCollection() + .AddSingleton(serviceProviderIsService) + .AddSingleton(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("1.0.0", document.Info.Version); + }); + } + + private static void ValidateOpenApiDocument(MemoryStream documentStream, Action action) + { + var document = new OpenApiStringReader().Read(Encoding.UTF8.GetString(documentStream.ToArray()), out var diagnostic); + Assert.Empty(diagnostic.Errors); + action(document); + } +} 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..cd43e19b2815 --- /dev/null +++ b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs @@ -0,0 +1,141 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.ApiDescriptions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +public class OpenApiServiceCollectionExtensions +{ + [Fact] + public void AddOpenApi_WithDocumentName_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + var returnedServices = services.AddOpenApi(documentName); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithDocumentName_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services.AddOpenApi(documentName); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithDocumentNameAndConfigureOptions_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + var returnedServices = services.AddOpenApi(documentName, options => { }); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithDocumentNameAndConfigureOptions_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services.AddOpenApi(documentName, options => { }); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithoutDocumentName_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var returnedServices = services.AddOpenApi(); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithoutDocumentName_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v1"; + + // Act + services.AddOpenApi(); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithConfigureOptions_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var returnedServices = services.AddOpenApi(options => { }); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithConfigureOptions_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v1"; + + // Act + services.AddOpenApi(options => { }); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } +} 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 @@ + 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(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(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 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 From c4ee086cb9829621efddce7ab603affa11d35f99 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 27 Mar 2024 10:15:28 -0700 Subject: [PATCH 2/7] Apply suggestions from code review Co-authored-by: Martin Costello Co-authored-by: Rick Anderson <3605364+Rick-Anderson@users.noreply.github.com> --- .../Extensions/OpenApiServiceCollectionExtensions.cs | 12 ++++++------ src/OpenApi/src/Services/IDocumentProvider.cs | 12 ++++++------ src/OpenApi/src/Services/OpenApiDocumentProvider.cs | 6 +++--- src/OpenApi/src/Services/OpenApiOptions.cs | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs index 95cf6c994ba0..3569a6e86de3 100644 --- a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs @@ -12,10 +12,10 @@ namespace Microsoft.Extensions.DependencyInjection; public static class OpenApiServiceCollectionExtensions { /// - /// Adds OpenAPI services related to the given document documentName to the specified . + /// Adds OpenAPI services related to the given document name to the specified . /// /// The to register services onto. - /// The documentName of the OpenAPI document associated with registered services. + /// The name of the OpenAPI document associated with registered services. public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName) { ArgumentNullException.ThrowIfNull(services); @@ -26,11 +26,11 @@ public static IServiceCollection AddOpenApi(this IServiceCollection services, st } /// - /// Adds OpenAPI services related to the given document documentName to the specified with the specified options. + /// Adds OpenAPI services related to the given document name to the specified with the specified options. /// /// The to register services onto. - /// The documentName of the OpenAPI document associated with registered services. - /// A function used to configure the target . + /// The name of the OpenAPI document associated with registered services. + /// A delegate used to configure the target . public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action configureOptions) { ArgumentNullException.ThrowIfNull(services); @@ -49,7 +49,7 @@ public static IServiceCollection AddOpenApi(this IServiceCollection services, st /// Adds OpenAPI services related to the default document to the specified with the specified options. /// /// The to register services onto. - /// A function used to configure the target . + /// A delegate used to configure the target . public static IServiceCollection AddOpenApi(this IServiceCollection services, Action configureOptions) => services.AddOpenApi(OpenApiConstants.DefaultDocumentName, configureOptions); diff --git a/src/OpenApi/src/Services/IDocumentProvider.cs b/src/OpenApi/src/Services/IDocumentProvider.cs index 749772f14988..61ef9dc560fe 100644 --- a/src/OpenApi/src/Services/IDocumentProvider.cs +++ b/src/OpenApi/src/Services/IDocumentProvider.cs @@ -8,12 +8,12 @@ namespace Microsoft.Extensions.ApiDescriptions; /// /// /// The Microsoft.Extensions.ApiDescription.Server package and associated configuration -/// will execute the `dotnet getdocument` command at build-time to support build-time -/// generation of documents. The getdocument tool will launch the entry point assembly -/// and query it for service that implements the `IDocumentProvider` interface. For -/// historical reasons, the `IDocumentProvider` interface is not exposed publicly from -/// the framework and the `getdocument` tool will instead query for it using type name. -/// That means we must declare the `IDocumentProvider` interface under the namespace +/// execute the `dotnet getdocument` command at build-time to support build-time +/// generation of documents. The `getdocument` tool launches the entry point assembly +/// and queries it for a service that implements the `IDocumentProvider` interface. For +/// historical reasons, the `IDocumentProvider` interface isn't exposed publicly from +/// the framework and the `getdocument` tool instead queries for it using the type name. +/// That means the `IDocumentProvider` interface must be declared under the namespace /// that it expects. For more information, see https://github.com/dotnet/aspnetcore/blob/82c9b34d7206ba56ea1d641843e1f2fe6d2a0b1c/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs#L25. /// internal interface IDocumentProvider diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs index 8422aae1d7c5..2fd67bad488c 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -33,9 +33,9 @@ public async Task GenerateAsync(string documentName, TextWriter writer) /// public IEnumerable GetDocumentNames() { - // Keyed services don't provide an accessible API for resolving all of the - // registered keys, so we'll use the service provider to resolve an internal - // type we use to track the document names that have been registered. + // Keyed services lack an API to resolve all registered keys. + // We use the service provider to resolve an internal type. + // This type tracks registered document names. // See https://github.com/dotnet/runtime/issues/100105 for more info. var documentServices = serviceProvider.GetServices>(); return documentServices.Select(docService => docService.Name); diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index 94c2c29db204..5498981363fe 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -31,7 +31,7 @@ public OpenApiOptions() public string DocumentName { get; internal set; } = OpenApiConstants.DefaultDocumentName; /// - /// A function to determine whether a given should be included in the given OpenAPI document. + /// A delegate to determine whether a given should be included in the given OpenAPI document. /// public Func ShouldInclude { get; set; } } From 8d05001566343d42756b642d89706549722e7166 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 27 Mar 2024 11:51:44 -0700 Subject: [PATCH 3/7] Update docs and pass cancellation token to WriteAsync --- .../src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs | 2 +- src/OpenApi/src/Services/OpenApiComponentService.cs | 4 +++- src/OpenApi/src/Services/OpenApiDocumentProvider.cs | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index 0ec0c73b6a1a..420d74a55899 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -43,7 +43,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e document.Serialize(jsonWriter, documentOptions.OpenApiVersion); context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json;charset=utf-8"; - await context.Response.WriteAsync(stringWriter.ToString(), new UTF8Encoding(false)); + await context.Response.WriteAsync(stringWriter.ToString(), new UTF8Encoding(false), context.RequestAborted); }).ExcludeFromDescription(); } } diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs index 787d44835d55..f5cf3636f1fe 100644 --- a/src/OpenApi/src/Services/OpenApiComponentService.cs +++ b/src/OpenApi/src/Services/OpenApiComponentService.cs @@ -8,4 +8,6 @@ namespace Microsoft.AspNetCore.OpenApi; /// an OpenAPI document. In particular, this is the API that is used to /// interact with the JSON schemas that are managed by a given OpenAPI document. /// -internal class OpenApiComponentService { } +internal class OpenApiComponentService +{ +} diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs index 2fd67bad488c..64dedabc231f 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -20,6 +20,9 @@ internal class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocu /// A text writer associated with the document to write to. public async Task GenerateAsync(string documentName, TextWriter writer) { + // Microsoft.OpenAPI does not provide async APIs for writing the JSON + // document to a file. See https://github.com/microsoft/OpenAPI.NET/issues/421 for + // more info. var targetDocumentService = serviceProvider.GetRequiredKeyedService(documentName); var options = serviceProvider.GetRequiredService>(); var namedOption = options.Get(documentName); From f1547127bcc4a29b403e80db3e11c6a82adc8600 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 28 Mar 2024 10:54:22 -0700 Subject: [PATCH 4/7] Address feedback --- .../OpenApiEndpointRouteBuilderExtensions.cs | 32 +++++---- src/OpenApi/src/Services/NamedService.cs | 18 ++--- .../src/Services/OpenApiComponentService.cs | 2 +- src/OpenApi/src/Services/OpenApiConstants.cs | 2 + .../src/Services/OpenApiDocumentProvider.cs | 2 +- .../src/Services/OpenApiDocumentService.cs | 6 +- ...nApiEndpointRouteBuilderExtensionsTests.cs | 65 ++++++++++++++++++- ...OpenApiServiceCollectionExtensionsTests.cs | 25 +++++++ 8 files changed, 123 insertions(+), 29 deletions(-) diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index 420d74a55899..c629c544211d 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Routing; @@ -26,24 +25,33 @@ public static class OpenApiEndpointRouteBuilderExtensions /// The . /// The route to register the endpoint on. Must include the 'documentName' route parameter. /// An that can be used to further customize the endpoint. - public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "/openapi/{documentName}.json") + public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = OpenApiConstants.DefaultOpenApiRoute) { var options = endpoints.ServiceProvider.GetRequiredService>(); - return endpoints.MapGet(pattern, async (string documentName, HttpContext context) => + 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.GetRequiredKeyedService(documentName); - var document = await documentService.GetOpenApiDocumentAsync(); - var documentOptions = options.Get(documentName); - using var stringWriter = new StringWriter(CultureInfo.InvariantCulture); - var jsonWriter = new OpenApiJsonWriter(stringWriter); - document.Serialize(jsonWriter, documentOptions.OpenApiVersion); - context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "application/json;charset=utf-8"; - await context.Response.WriteAsync(stringWriter.ToString(), new UTF8Encoding(false), context.RequestAborted); + var documentService = context.RequestServices.GetKeyedService(documentName); + if (documentService is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.ContentType = "text/plain;charset=utf-8"; + await context.Response.WriteAsync($"No OpenAPI document with the name '{documentName}' was found."); + } + else + { + var document = await documentService.GetOpenApiDocumentAsync(); + var documentOptions = options.Get(documentName); + using var stringWriter = new StringWriter(CultureInfo.InvariantCulture); + var jsonWriter = new OpenApiJsonWriter(stringWriter); + document.Serialize(jsonWriter, documentOptions.OpenApiVersion); + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json;charset=utf-8"; + await context.Response.WriteAsync(stringWriter.ToString(), context.RequestAborted); + } }).ExcludeFromDescription(); } } diff --git a/src/OpenApi/src/Services/NamedService.cs b/src/OpenApi/src/Services/NamedService.cs index b9064d7229bb..ca1545313a76 100644 --- a/src/OpenApi/src/Services/NamedService.cs +++ b/src/OpenApi/src/Services/NamedService.cs @@ -3,14 +3,16 @@ namespace Microsoft.AspNetCore.OpenApi; -// Keyed services don't provide an accessible API for resolving -// all the service keys associated with a given type. -// See https://github.com/dotnet/runtime/issues/100105 for more info. -// This internal class is used to track the document names that have been registered -// so that they can be resolved in the `IDocumentProvider` implementation. -// This is inspired by the implementation used in Orleans. See -// https://github.com/dotnet/orleans/blob/005ab200bc91302245857cb75efaa436296a1aae/src/Orleans.Runtime/Hosting/NamedService.cs. -internal class NamedService(string name) +/// +/// Keyed services don't provide an accessible API for resolving +/// all the service keys associated with a given type. +/// See https:///github.com/dotnet/runtime/issues/100105 for more info. +/// This internal class is used to track the document names that have been registered +/// so that they can be resolved in the `IDocumentProvider` implementation. +/// This is inspired by the implementation used in Orleans. See +/// https:///github.com/dotnet/orleans/blob/005ab200bc91302245857cb75efaa436296a1aae/src/Orleans.Runtime/Hosting/NamedService.cs. +/// +internal sealed class NamedService(string name) { public string Name { get; } = name; } diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs index f5cf3636f1fe..f327ee1c34b3 100644 --- a/src/OpenApi/src/Services/OpenApiComponentService.cs +++ b/src/OpenApi/src/Services/OpenApiComponentService.cs @@ -8,6 +8,6 @@ namespace Microsoft.AspNetCore.OpenApi; /// an OpenAPI document. In particular, this is the API that is used to /// interact with the JSON schemas that are managed by a given OpenAPI document. /// -internal class OpenApiComponentService +internal sealed class OpenApiComponentService { } diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index 54f2d8387c7d..801493beb2fb 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -6,4 +6,6 @@ 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 index 64dedabc231f..831475f8960a 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.ApiDescriptions; -internal class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocumentProvider +internal sealed class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocumentProvider { /// /// Serializes the OpenAPI document associated with a given document name to diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index a18ef02f4d51..6bab28a32110 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -6,10 +6,8 @@ namespace Microsoft.AspNetCore.OpenApi; -internal class OpenApiDocumentService(IHostEnvironment hostEnvironment) +internal sealed class OpenApiDocumentService(IHostEnvironment hostEnvironment) { - private readonly string _defaultOpenApiVersion = "1.0.0"; - public Task GetOpenApiDocumentAsync() { var document = new OpenApiDocument @@ -17,7 +15,7 @@ public Task GetOpenApiDocumentAsync() Info = new OpenApiInfo { Title = hostEnvironment.ApplicationName, - Version = _defaultOpenApiVersion + Version = OpenApiConstants.DefaultOpenApiVersion } }; return Task.FromResult(document); diff --git a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs index 05b5f6a973b3..45a10cc1b3aa 100644 --- a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs +++ b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs @@ -4,9 +4,6 @@ using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using System.Text.Json; -using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Readers; using System.Text; @@ -83,10 +80,72 @@ public async Task MapOpenApi_ReturnsRenderedDocument() 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(serviceProviderIsService) + .AddSingleton(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(serviceProviderIsService) + .AddSingleton(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())); + } + private static void ValidateOpenApiDocument(MemoryStream documentStream, Action action) { var document = new OpenApiStringReader().Read(Encoding.UTF8.GetString(documentStream.ToArray()), out var diagnostic); diff --git a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs index cd43e19b2815..766e11959d14 100644 --- a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs +++ b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.ApiDescriptions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.OpenApi; public class OpenApiServiceCollectionExtensions { @@ -138,4 +139,28 @@ public void AddOpenApi_WithConfigureOptions_RegistersServices() var namedOption = options.Get(documentName); Assert.Equal(documentName, namedOption.DocumentName); } + + [Fact] + public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services + .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0) + .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + // Verify last registration is used + Assert.Equal(OpenApiSpecVersion.OpenApi3_0, namedOption.OpenApiVersion); + } } From b2984a639b67543f9ef45b0d7a59845cb4450092 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 28 Mar 2024 11:11:01 -0700 Subject: [PATCH 5/7] Remove trailing comma in slnf --- src/OpenApi/OpenApi.slnf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf index b592105d50fc..ca74ac85ba10 100644 --- a/src/OpenApi/OpenApi.slnf +++ b/src/OpenApi/OpenApi.slnf @@ -9,7 +9,7 @@ "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", "src\\OpenApi\\src\\Microsoft.AspNetCore.OpenApi.csproj", "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj", - "src\\OpenApi\\sample\\Sample.csproj", + "src\\OpenApi\\sample\\Sample.csproj" ] } } From cda4b37b45bf785e8104172936b4641f627f33d4 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 29 Mar 2024 10:45:13 -0700 Subject: [PATCH 6/7] Address more feedback --- .../OpenApiEndpointRouteBuilderExtensions.cs | 22 ++++++++---- .../OpenApiServiceCollectionExtensions.cs | 5 ++- .../src/Microsoft.AspNetCore.OpenApi.csproj | 6 ++++ ...nApiEndpointRouteBuilderExtensionsTests.cs | 34 +++++++++++++++++++ ...OpenApiServiceCollectionExtensionsTests.cs | 23 +++++++++++++ 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index c629c544211d..052bc13a2554 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using System.Globalization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -45,12 +45,20 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e { var document = await documentService.GetOpenApiDocumentAsync(); var documentOptions = options.Get(documentName); - using var stringWriter = new StringWriter(CultureInfo.InvariantCulture); - var jsonWriter = new OpenApiJsonWriter(stringWriter); - document.Serialize(jsonWriter, documentOptions.OpenApiVersion); - context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "application/json;charset=utf-8"; - await context.Response.WriteAsync(stringWriter.ToString(), context.RequestAborted); + 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 index 3569a6e86de3..b7372551a0ac 100644 --- a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs @@ -20,9 +20,7 @@ public static IServiceCollection AddOpenApi(this IServiceCollection services, st { ArgumentNullException.ThrowIfNull(services); - services.AddOpenApiCore(documentName); - services.Configure(documentName, options => options.DocumentName = documentName); - return services; + return services.AddOpenApi(documentName, _ => { }); } /// @@ -67,6 +65,7 @@ private static IServiceCollection AddOpenApiCore(this IServiceCollection service services.AddKeyedSingleton(documentName); // Required for build-time generation services.AddSingleton(); + // Required to resolve document names for build-time generation services.AddSingleton(new NamedService(documentName)); return services; } diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 5032c3d41761..ac447a7cc31d 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -4,6 +4,7 @@ $(DefaultNetCoreTargetFramework) true aspnetcore;openapi + true Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations. @@ -28,4 +29,9 @@ + + + + + diff --git a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs index 45a10cc1b3aa..7dbb615e311a 100644 --- a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs +++ b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs @@ -146,6 +146,40 @@ public async Task MapOpenApi_Returns404ForUnresolvedDocument() 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(serviceProviderIsService) + .AddSingleton(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 action) { var document = new OpenApiStringReader().Read(Encoding.UTF8.GetString(documentStream.ToArray()), out var diagnostic); diff --git a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs index 766e11959d14..f297c1c3677c 100644 --- a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs +++ b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs @@ -163,4 +163,27 @@ public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration() // Verify last registration is used Assert.Equal(OpenApiSpecVersion.OpenApi3_0, namedOption.OpenApiVersion); } + + [Fact] + public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration_ValidateOptionsOverride() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services + .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0) + .AddOpenApi(documentName); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + Assert.Equal(OpenApiSpecVersion.OpenApi2_0, namedOption.OpenApiVersion); + } } From ef6fcf7127d3f199f6b175de2cc86e6c24d285ad Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 29 Mar 2024 10:48:43 -0700 Subject: [PATCH 7/7] Seal OpenApiOptions --- src/OpenApi/src/Services/OpenApiOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index 5498981363fe..efe92b30dfb5 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.OpenApi; /// /// Options to support the construction of OpenAPI documents. /// -public class OpenApiOptions +public sealed class OpenApiOptions { /// /// Initializes a new instance of the class