From 46554c9183e7ebdda173edd03038f19ea7ac75d3 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 18 Apr 2022 21:27:45 -0700 Subject: [PATCH] Add Microsoft.AspNetCore.OpenApi package (#41238) * Add Microsoft.AspNetCore.OpenApi package Fix up schema type generation Post review polish Add new package to templates Split out SchemaGenerator Clean up usings * Address feedback from peer review * Move OpenApi package to top-level directory in src * Clean up HttpAbstractions filter * Update ProjectReferences and templates * Fix up ProjectReferences and address feedback * Add new directory to Build.props and clean up sln --- AspNetCore.sln | 41 + eng/Build.props | 2 + eng/Dependencies.props | 1 + eng/ProjectReferences.props | 1 + eng/Versions.props | 1 + .../src/Extensions/EndpointBuilder.cs | 5 + .../src/PublicAPI.Unshipped.txt | 2 + .../Builder/EndpointRouteBuilderExtensions.cs | 1 + .../Routing/src/Properties/AssemblyInfo.cs | 1 + src/OpenApi/OpenApi.slnf | 14 + src/OpenApi/README.md | 5 + src/OpenApi/build.cmd | 3 + src/OpenApi/build.sh | 7 + .../src/Microsoft.AspNetCore.OpenApi.csproj | 28 + src/OpenApi/src/OpenApiGenerator.cs | 474 +++++++++++ .../OpenApiRouteHandlerBuilderExtensions.cs | 82 ++ src/OpenApi/src/PublicAPI.Shipped.txt | 1 + src/OpenApi/src/PublicAPI.Unshipped.txt | 4 + src/OpenApi/src/SchemaGenerator.cs | 44 + src/OpenApi/startvs.cmd | 3 + .../Microsoft.AspNetCore.OpenApi.Tests.csproj | 19 + src/OpenApi/test/OpenApiGeneratorTests.cs | 804 ++++++++++++++++++ ...penApiRouteHandlerBuilderExtensionTests.cs | 65 ++ ...crosoft.DotNet.Web.ProjectTemplates.csproj | 1 + .../WebApi-CSharp.csproj.in | 1 + ...gram.MinimalAPIs.OrgOrIndividualB2CAuth.cs | 4 + .../Program.MinimalAPIs.WindowsOrNoAuth.cs | 7 +- 27 files changed, 1620 insertions(+), 1 deletion(-) create mode 100644 src/OpenApi/OpenApi.slnf create mode 100644 src/OpenApi/README.md create mode 100644 src/OpenApi/build.cmd create mode 100644 src/OpenApi/build.sh create mode 100644 src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj create mode 100644 src/OpenApi/src/OpenApiGenerator.cs create mode 100644 src/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs create mode 100644 src/OpenApi/src/PublicAPI.Shipped.txt create mode 100644 src/OpenApi/src/PublicAPI.Unshipped.txt create mode 100644 src/OpenApi/src/SchemaGenerator.cs create mode 100644 src/OpenApi/startvs.cmd create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj create mode 100644 src/OpenApi/test/OpenApiGeneratorTests.cs create mode 100644 src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs diff --git a/AspNetCore.sln b/AspNetCore.sln index de3f6f05c9b7..0ba416a9b244 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1696,6 +1696,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildAfterTargetingPack", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResultsOfTGenerator", "src\Http\Http.Results\tools\ResultsOfTGenerator\ResultsOfTGenerator.csproj", "{9716D0D0-2251-44DD-8596-67D253EEF41C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenApi", "OpenApi", "{2299CCD8-8F9C-4F2B-A633-9BF4DA81022B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Tests", "src\OpenApi\test\Microsoft.AspNetCore.OpenApi.Tests.csproj", "{3AEFB466-6310-4F3F-923F-9154224E3629}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi", "src\OpenApi\src\Microsoft.AspNetCore.OpenApi.csproj", "{EFC8EA45-572D-4D8D-A597-9045A2D8EC40}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10153,6 +10159,38 @@ Global {9716D0D0-2251-44DD-8596-67D253EEF41C}.Release|x64.Build.0 = Release|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Release|x86.ActiveCfg = Release|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Release|x86.Build.0 = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|arm64.ActiveCfg = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|arm64.Build.0 = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|x64.ActiveCfg = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|x64.Build.0 = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|x86.ActiveCfg = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Debug|x86.Build.0 = Debug|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|Any CPU.Build.0 = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|arm64.ActiveCfg = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|arm64.Build.0 = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|x64.ActiveCfg = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|x64.Build.0 = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|x86.ActiveCfg = Release|Any CPU + {3AEFB466-6310-4F3F-923F-9154224E3629}.Release|x86.Build.0 = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|arm64.ActiveCfg = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|arm64.Build.0 = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|x64.ActiveCfg = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|x64.Build.0 = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|x86.ActiveCfg = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Debug|x86.Build.0 = Debug|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|Any CPU.Build.0 = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|arm64.ActiveCfg = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|arm64.Build.0 = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x64.ActiveCfg = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x64.Build.0 = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x86.ActiveCfg = Release|Any CPU + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -10993,6 +11031,9 @@ Global {489020F2-80D9-4468-A5D3-07E785837A5D} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {8FED7E65-A7DD-4F13-8980-BF03E77B6C85} = {489020F2-80D9-4468-A5D3-07E785837A5D} {9716D0D0-2251-44DD-8596-67D253EEF41C} = {323C3EB6-1D15-4B3D-918D-699D7F64DED9} + {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} + {3AEFB466-6310-4F3F-923F-9154224E3629} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} + {EFC8EA45-572D-4D8D-A597-9045A2D8EC40} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Build.props b/eng/Build.props index a39342be7da3..afd7af63effd 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -198,6 +198,7 @@ $(RepoRoot)src\submodules\spa-templates\src\*.csproj; $(RepoRoot)src\Extensions\**\*.csproj; $(RepoRoot)src\BuildAfterTargetingPack\*.csproj; + $(RepoRoot)src\OpenApi\**\*.csproj; " Exclude=" @(ProjectToBuild); @@ -238,6 +239,7 @@ $(RepoRoot)src\Testing\**\src\*.csproj; $(RepoRoot)src\Extensions\**\src\*.csproj; $(RepoRoot)src\BuildAfterTargetingPack\*.csproj; + $(RepoRoot)src\OpenApi\**\src\*.csproj; " Exclude=" @(ProjectToBuild); diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 18be2fda9f68..0fbdbd667a71 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -62,6 +62,7 @@ and are generated based on the last package release. + diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 94a43b1c5331..33d4d05ed215 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -159,5 +159,6 @@ + diff --git a/eng/Versions.props b/eng/Versions.props index ecbcd26e3568..bf1ff64e74e8 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -282,6 +282,7 @@ 2.4.3 4.0.1 6.0.0-preview.3.21167.1 + 1.2.3 diff --git a/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs b/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs index e133399f4ad0..873a1dbe8bae 100644 --- a/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs +++ b/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs @@ -25,6 +25,11 @@ public abstract class EndpointBuilder /// public IList Metadata { get; } = new List(); + /// + /// Gets the associated with the endpoint. + /// + public IServiceProvider? ServiceProvider { get; set; } + /// /// Creates an instance of from the . /// diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index dc7700f1d4a5..4532909a61d7 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,5 +1,7 @@ #nullable enable *REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string! +Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.get -> System.IServiceProvider? +Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.set -> void Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index 1c25bbede9c7..7af7e03f097d 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -507,6 +507,7 @@ private static RouteHandlerBuilder Map( defaultOrder) { DisplayName = pattern.RawText ?? pattern.DebuggerToString(), + ServiceProvider = endpoints.ServiceProvider, }; // Methods defined in a top-level program are generated as statics so the delegate diff --git a/src/Http/Routing/src/Properties/AssemblyInfo.cs b/src/Http/Routing/src/Properties/AssemblyInfo.cs index 93c67c32e9c9..f9e75169e796 100644 --- a/src/Http/Routing/src/Properties/AssemblyInfo.cs +++ b/src/Http/Routing/src/Properties/AssemblyInfo.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.OpenApi.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Routing.Microbenchmarks, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Routing.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf new file mode 100644 index 000000000000..0311c6b7ddcd --- /dev/null +++ b/src/OpenApi/OpenApi.slnf @@ -0,0 +1,14 @@ +{ + "solution": { + "path": "..\\..\\AspNetCore.sln", + "projects": [ + "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", + "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", + "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", + "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" + ] + } +} \ No newline at end of file diff --git a/src/OpenApi/README.md b/src/OpenApi/README.md new file mode 100644 index 000000000000..345489e4105b --- /dev/null +++ b/src/OpenApi/README.md @@ -0,0 +1,5 @@ +# Microsoft.AspNetCore.OpenApi + +This directory contains the source for the `Microsoft.AspNetCore.OpenApi` package which provides support for +generating OpenApi schemas directly for route handler endpoints in ASP.NET Core. + diff --git a/src/OpenApi/build.cmd b/src/OpenApi/build.cmd new file mode 100644 index 000000000000..cee462c96fa1 --- /dev/null +++ b/src/OpenApi/build.cmd @@ -0,0 +1,3 @@ +@ECHO OFF +SET RepoRoot=%~dp0..\.. +%RepoRoot%\eng\build.cmd -projects %~dp0**\*.*proj %* \ No newline at end of file diff --git a/src/OpenApi/build.sh b/src/OpenApi/build.sh new file mode 100644 index 000000000000..491835c0cb2b --- /dev/null +++ b/src/OpenApi/build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +repo_root="$DIR/../.." +"$repo_root/eng/build.sh" --projects "$DIR/**/*.*proj" "$@" \ No newline at end of file diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj new file mode 100644 index 000000000000..91cc8230727b --- /dev/null +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -0,0 +1,28 @@ + + + + $(DefaultNetCoreTargetFramework) + true + aspnetcore;openapi + Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations. + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/OpenApiGenerator.cs new file mode 100644 index 000000000000..e8f2f59d5b11 --- /dev/null +++ b/src/OpenApi/src/OpenApiGenerator.cs @@ -0,0 +1,474 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Primitives; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Defines a set of methods for generating OpenAPI definitions for endpoints. +/// +internal class OpenApiGenerator +{ + private readonly IHostEnvironment? _environment; + private readonly IServiceProviderIsService? _serviceProviderIsService; + + internal static readonly ParameterBindingMethodCache ParameterBindingMethodCache = new(); + + /// + /// Creates an instance given an + /// and an instance. + /// + /// The host environment. + /// The service to determine if the type is available from the . + internal OpenApiGenerator( + IHostEnvironment? environment, + IServiceProviderIsService? serviceProviderIsService) + { + _environment = environment; + _serviceProviderIsService = serviceProviderIsService; + } + + /// + /// Generates an for a given . + /// + /// The associated with the route handler of the endpoint. + /// The endpoint . + /// The route pattern. + /// An annotation derived from the given inputs. + internal OpenApiOperation? GetOpenApiOperation( + MethodInfo methodInfo, + EndpointMetadataCollection metadata, + RoutePattern pattern) + { + if (metadata.GetMetadata() is { } httpMethodMetadata && + httpMethodMetadata.HttpMethods.SingleOrDefault() is { } method && + metadata.GetMetadata() is null or { ExcludeFromDescription: false }) + { + return GetOperation(method, methodInfo, metadata, pattern); + } + + return null; + } + + private OpenApiOperation GetOperation(string httpMethod, MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern) + { + var disableInferredBody = ShouldDisableInferredBody(httpMethod); + return new OpenApiOperation + { + OperationId = metadata.GetMetadata()?.EndpointName, + Summary = metadata.GetMetadata()?.Summary, + Description = metadata.GetMetadata()?.Description, + Tags = GetOperationTags(methodInfo, metadata), + Parameters = GetOpenApiParameters(methodInfo, metadata, pattern, disableInferredBody), + RequestBody = GetOpenApiRequestBody(methodInfo, metadata, pattern), + Responses = GetOpenApiResponses(methodInfo, metadata) + }; + + static bool ShouldDisableInferredBody(string method) + { + // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies + return method.Equals(HttpMethods.Get, StringComparison.Ordinal) || + method.Equals(HttpMethods.Delete, StringComparison.Ordinal) || + method.Equals(HttpMethods.Head, StringComparison.Ordinal) || + method.Equals(HttpMethods.Options, StringComparison.Ordinal) || + method.Equals(HttpMethods.Trace, StringComparison.Ordinal) || + method.Equals(HttpMethods.Connect, StringComparison.Ordinal); + } + } + + private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointMetadataCollection metadata) + { + var responses = new OpenApiResponses(); + var responseType = method.ReturnType; + if (AwaitableInfo.IsTypeAwaitable(responseType, out var awaitableInfo)) + { + responseType = awaitableInfo.ResultType; + } + + if (typeof(IResult).IsAssignableFrom(responseType)) + { + responseType = typeof(void); + } + + var errorMetadata = metadata.GetMetadata(); + var defaultErrorType = errorMetadata?.Type; + + var responseProviderMetadata = metadata.GetOrderedMetadata(); + var producesResponseMetadata = metadata.GetOrderedMetadata(); + + var eligibileAnnotations = new Dictionary(); + + foreach (var responseMetadata in producesResponseMetadata) + { + var statusCode = responseMetadata.StatusCode; + + var discoveredTypeAnnotation = responseMetadata.Type; + var discoveredContentTypeAnnotation = new MediaTypeCollection(); + + if (discoveredTypeAnnotation == typeof(void)) + { + if (responseType != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + { + discoveredTypeAnnotation = responseType; + } + } + + foreach (var contentType in responseMetadata.ContentTypes) + { + discoveredContentTypeAnnotation.Add(contentType); + } + + discoveredTypeAnnotation = discoveredTypeAnnotation == null || discoveredTypeAnnotation == typeof(void) + ? responseType + : discoveredTypeAnnotation; + + if (discoveredTypeAnnotation is not null) + { + GenerateDefaultContent(discoveredContentTypeAnnotation, discoveredTypeAnnotation); + eligibileAnnotations.Add(statusCode, (discoveredTypeAnnotation, discoveredContentTypeAnnotation)); + } + } + + foreach (var providerMetadata in responseProviderMetadata) + { + var statusCode = providerMetadata.StatusCode; + + var discoveredTypeAnnotation = providerMetadata.Type; + var discoveredContentTypeAnnotation = new MediaTypeCollection(); + + if (discoveredTypeAnnotation == typeof(void)) + { + if (responseType != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + { + // ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified. + // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a + // [ProducesResponseType(201)] instead of [ProducesResponseType(typeof(Person), 201] when typeof(Person) can be inferred + // from the return type. + discoveredTypeAnnotation = responseType; + } + else if (statusCode >= 400 && statusCode < 500) + { + // Determine whether or not the type was provided by the user. If so, favor it over the default + // error type for 4xx client errors if no response type is specified. + discoveredTypeAnnotation = defaultErrorType is not null ? defaultErrorType : discoveredTypeAnnotation; + } + else if (providerMetadata is IApiDefaultResponseMetadataProvider) + { + discoveredTypeAnnotation = defaultErrorType; + } + } + + providerMetadata.SetContentTypes(discoveredContentTypeAnnotation); + + discoveredTypeAnnotation = discoveredTypeAnnotation == null || discoveredTypeAnnotation == typeof(void) + ? responseType + : discoveredTypeAnnotation; + + GenerateDefaultContent(discoveredContentTypeAnnotation, discoveredTypeAnnotation); + eligibileAnnotations.Add(statusCode, (discoveredTypeAnnotation, discoveredContentTypeAnnotation)); + } + + if (eligibileAnnotations.Count == 0) + { + GenerateDefaultResponses(eligibileAnnotations, responseType); + } + + foreach (var annotation in eligibileAnnotations) + { + var statusCode = annotation.Key.ToString(CultureInfo.InvariantCulture); + var (type, contentTypes) = annotation.Value; + var responseContent = new Dictionary(); + + foreach (var contentType in contentTypes) + { + responseContent[contentType] = new OpenApiMediaType + { + Schema = new OpenApiSchema { Type = SchemaGenerator.GetOpenApiSchemaType(type) } + }; + } + + responses[statusCode] = new OpenApiResponse { Content = responseContent }; + } + + return responses; + } + + private static void GenerateDefaultContent(MediaTypeCollection discoveredContentTypeAnnotation, Type? discoveredTypeAnnotation) + { + if (discoveredContentTypeAnnotation.Count == 0) + { + if (discoveredTypeAnnotation == typeof(void) || discoveredTypeAnnotation == null) + { + return; + } + if (discoveredTypeAnnotation == typeof(string)) + { + discoveredContentTypeAnnotation.Add("text/plain"); + } + else + { + discoveredContentTypeAnnotation.Add("application/json"); + } + } + } + + private static void GenerateDefaultResponses(Dictionary eligibleAnnotations, Type responseType) + { + if (responseType == typeof(void)) + { + eligibleAnnotations.Add(StatusCodes.Status200OK, (responseType, new MediaTypeCollection())); + } + else if (responseType == typeof(string)) + { + eligibleAnnotations.Add(StatusCodes.Status200OK, (responseType, new MediaTypeCollection() { "text/plain" })); + } + else + { + eligibleAnnotations.Add(StatusCodes.Status200OK, (responseType, new MediaTypeCollection() { "application/json" })); + } + } + + private OpenApiRequestBody? GetOpenApiRequestBody(MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern) + { + var hasFormOrBodyParameter = false; + ParameterInfo? requestBodyParameter = null; + + foreach (var parameter in methodInfo.GetParameters()) + { + var (bodyOrFormParameter, _) = GetOpenApiParameterLocation(parameter, pattern, false); + hasFormOrBodyParameter |= bodyOrFormParameter; + if (hasFormOrBodyParameter) + { + requestBodyParameter = parameter; + break; + } + } + + var acceptsMetadata = metadata.GetMetadata(); + var requestBodyContent = new Dictionary(); + var isRequired = false; + + if (acceptsMetadata is not null) + { + foreach (var contentType in acceptsMetadata.ContentTypes) + { + requestBodyContent[contentType] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = SchemaGenerator.GetOpenApiSchemaType(acceptsMetadata.RequestType ?? requestBodyParameter?.ParameterType) + } + }; + } + isRequired = !acceptsMetadata.IsOptional; + } + + if (!hasFormOrBodyParameter) + { + return new OpenApiRequestBody() + { + Required = isRequired, + Content = requestBodyContent + }; + } + + if (requestBodyParameter is not null) + { + if (requestBodyContent.Count == 0) + { + var isFormType = requestBodyParameter.ParameterType == typeof(IFormFile) || requestBodyParameter.ParameterType == typeof(IFormFileCollection); + var hasFormAttribute = requestBodyParameter.GetCustomAttributes().OfType().FirstOrDefault() != null; + if (isFormType || hasFormAttribute) + { + requestBodyContent["multipart/form-data"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = SchemaGenerator.GetOpenApiSchemaType(requestBodyParameter.ParameterType) + } + }; + } + else + { + requestBodyContent["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = SchemaGenerator.GetOpenApiSchemaType(requestBodyParameter.ParameterType) + } + }; + } + } + + var nullabilityContext = new NullabilityInfoContext(); + var nullability = nullabilityContext.Create(requestBodyParameter); + var allowEmpty = requestBodyParameter.GetCustomAttributes().OfType().SingleOrDefault()?.AllowEmpty ?? false; + var isOptional = requestBodyParameter.HasDefaultValue + || nullability.ReadState != NullabilityState.NotNull + || allowEmpty; + + return new OpenApiRequestBody + { + Required = !isOptional, + Content = requestBodyContent + }; + } + + return null; + } + + private List GetOperationTags(MethodInfo methodInfo, EndpointMetadataCollection metadata) + { + var tags = metadata.GetMetadata(); + string controllerName; + + if (methodInfo.DeclaringType is not null && !TypeHelper.IsCompilerGeneratedType(methodInfo.DeclaringType)) + { + controllerName = methodInfo.DeclaringType.Name; + } + else + { + // If the declaring type is null or compiler-generated (e.g. lambdas), + // group the methods under the application name. + controllerName = _environment?.ApplicationName ?? string.Empty; + } + + return tags is not null + ? tags.Tags.Select(tag => new OpenApiTag() { Name = tag }).ToList() + : new List() { new OpenApiTag() { Name = controllerName } }; + } + + private List GetOpenApiParameters(MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern, bool disableInferredBody) + { + var parameters = methodInfo.GetParameters(); + var openApiParameters = new List(); + + foreach (var parameter in parameters) + { + var (isBodyOrFormParameter, parameterLocation) = GetOpenApiParameterLocation(parameter, pattern, disableInferredBody); + + // If the parameter isn't something that would be populated in RequestBody + // or doesn't have a valid ParameterLocation, then it must be a service + // parameter that we can ignore. + if (!isBodyOrFormParameter && parameterLocation is null) + { + continue; + } + + var nullabilityContext = new NullabilityInfoContext(); + var nullability = nullabilityContext.Create(parameter); + var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull; + var openApiParameter = new OpenApiParameter() + { + Name = parameter.Name, + In = parameterLocation, + Content = GetOpenApiParameterContent(metadata), + Schema = new OpenApiSchema { Type = SchemaGenerator.GetOpenApiSchemaType(parameter.ParameterType) }, + Required = !isOptional + + }; + openApiParameters.Add(openApiParameter); + } + + return openApiParameters; + } + + private static Dictionary GetOpenApiParameterContent(EndpointMetadataCollection metadata) + { + var openApiParameterContent = new Dictionary(); + var acceptsMetadata = metadata.GetMetadata(); + if (acceptsMetadata is not null) + { + foreach (var contentType in acceptsMetadata.ContentTypes) + { + openApiParameterContent.Add(contentType, new OpenApiMediaType()); + } + } + + return openApiParameterContent; + } + + private (bool isBodyOrForm, ParameterLocation? locatedIn) GetOpenApiParameterLocation(ParameterInfo parameter, RoutePattern pattern, bool disableInferredBody) + { + var attributes = parameter.GetCustomAttributes(); + + if (attributes.OfType().FirstOrDefault() is { } routeAttribute) + { + return (false, ParameterLocation.Path); + } + else if (attributes.OfType().FirstOrDefault() is { } queryAttribute) + { + return (false, ParameterLocation.Query); + } + else if (attributes.OfType().FirstOrDefault() is { } headerAttribute) + { + return (false, ParameterLocation.Header); + } + else if (attributes.OfType().FirstOrDefault() is { } fromBodyAttribute) + { + return (true, null); + } + else if (attributes.OfType().FirstOrDefault() is { } fromFormAttribute) + { + return (true, null); + } + else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)) || + parameter.ParameterType == typeof(HttpContext) || + parameter.ParameterType == typeof(HttpRequest) || + parameter.ParameterType == typeof(HttpResponse) || + parameter.ParameterType == typeof(ClaimsPrincipal) || + parameter.ParameterType == typeof(CancellationToken) || + ParameterBindingMethodCache.HasBindAsyncMethod(parameter) || + _serviceProviderIsService?.IsService(parameter.ParameterType) == true) + { + return (false, null); + } + else if (parameter.ParameterType == typeof(string) || ParameterBindingMethodCache.HasTryParseMethod(parameter.ParameterType)) + { + // complex types will display as strings since they use custom parsing via TryParse on a string + var displayType = !parameter.ParameterType.IsPrimitive && Nullable.GetUnderlyingType(parameter.ParameterType)?.IsPrimitive != true + ? typeof(string) : parameter.ParameterType; + // Path vs query cannot be determined by RequestDelegateFactory at startup currently because of the layering, but can be done here. + if (parameter.Name is { } name && pattern.GetParameter(name) is not null) + { + return (false, ParameterLocation.Path); + } + else + { + return (false, ParameterLocation.Query); + } + } + else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection)) + { + return (true, null); + } + else if (disableInferredBody && ( + (parameter.ParameterType.IsArray && ParameterBindingMethodCache.HasTryParseMethod(parameter.ParameterType.GetElementType()!)) || + parameter.ParameterType == typeof(string[]) || + parameter.ParameterType == typeof(StringValues))) + { + return (false, ParameterLocation.Query); + } + else + { + return (true, null); + } + } +} diff --git a/src/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs b/src/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs new file mode 100644 index 000000000000..878ecc2bdfe0 --- /dev/null +++ b/src/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Extension methods for annotating OpenAPI descriptions on an . +/// +public static class OpenApiRouteHandlerBuilderExtensions +{ + /// + /// Adds an OpenAPI annotation to associated + /// with the current endpoint. + /// + /// The . + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder) + { + builder.Add(endpointBuilder => + { + if (endpointBuilder is RouteEndpointBuilder routeEndpointBuilder) + { + var openApiOperation = GetOperationForEndpoint(routeEndpointBuilder); + if (openApiOperation != null) + { + routeEndpointBuilder.Metadata.Add(openApiOperation); + } + }; + }); + return builder; + } + + /// + /// Adds an OpenAPI annotation to associated + /// with the current endpoint and modifies it with the given . + /// + /// The . + /// An that returns a new OpenAPI annotation given a generated operation. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder, Func configureOperation) + { + builder.Add(endpointBuilder => + { + if (endpointBuilder is RouteEndpointBuilder routeEndpointBuilder) + { + var openApiOperation = GetOperationForEndpoint(routeEndpointBuilder); + if (openApiOperation != null) + { + routeEndpointBuilder.Metadata.Add(configureOperation(openApiOperation)); + } + }; + }); + return builder; + } + + private static OpenApiOperation? GetOperationForEndpoint(RouteEndpointBuilder routeEndpointBuilder) + { + var pattern = routeEndpointBuilder.RoutePattern; + var metadata = new EndpointMetadataCollection(routeEndpointBuilder.Metadata); + var methodInfo = metadata.OfType().SingleOrDefault(); + var serviceProvider = routeEndpointBuilder.ServiceProvider; + + if (methodInfo == null || serviceProvider == null) + { + return null; + } + + var hostEnvironment = serviceProvider.GetService(); + var serviceProviderIsService = serviceProvider.GetService(); + var generator = new OpenApiGenerator(hostEnvironment, serviceProviderIsService); + return generator.GetOpenApiOperation(methodInfo, metadata, pattern); + } +} diff --git a/src/OpenApi/src/PublicAPI.Shipped.txt b/src/OpenApi/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/OpenApi/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..d83debd1c5d6 --- /dev/null +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +#nullable enable +Microsoft.AspNetCore.OpenApi.OpenApiRouteHandlerBuilderExtensions +static Microsoft.AspNetCore.OpenApi.OpenApiRouteHandlerBuilderExtensions.WithOpenApi(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.OpenApi.OpenApiRouteHandlerBuilderExtensions.WithOpenApi(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, System.Func! configureOperation) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/OpenApi/src/SchemaGenerator.cs b/src/OpenApi/src/SchemaGenerator.cs new file mode 100644 index 000000000000..3eeb16fe4e79 --- /dev/null +++ b/src/OpenApi/src/SchemaGenerator.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class SchemaGenerator +{ + internal static string GetOpenApiSchemaType(Type? inputType) + { + if (inputType == null) + { + throw new ArgumentNullException(nameof(inputType)); + } + + var type = Nullable.GetUnderlyingType(inputType) ?? inputType; + + if (typeof(string).IsAssignableFrom(type) || typeof(DateTime).IsAssignableTo(type)) + { + return "string"; + } + else if (typeof(bool).IsAssignableFrom(type)) + { + return "boolean"; + } + else if (typeof(int).IsAssignableFrom(type) + || typeof(double).IsAssignableFrom(type) + || typeof(float).IsAssignableFrom(type)) + { + return "number"; + } + else if (typeof(long).IsAssignableFrom(type)) + { + return "integer"; + } + else if (type.IsArray) + { + return "array"; + } + else + { + return "object"; + } + } +} diff --git a/src/OpenApi/startvs.cmd b/src/OpenApi/startvs.cmd new file mode 100644 index 000000000000..f568dedcccee --- /dev/null +++ b/src/OpenApi/startvs.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\startvs.cmd %~dp0OpenApi.slnf \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj new file mode 100644 index 000000000000..efa9290e2325 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj @@ -0,0 +1,19 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + + + + diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/OpenApiGeneratorTests.cs new file mode 100644 index 000000000000..459966c62f43 --- /dev/null +++ b/src/OpenApi/test/OpenApiGeneratorTests.cs @@ -0,0 +1,804 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.Tests; + +public class OpenApiOperationGeneratorTests +{ + [Fact] + public void OperationNotCreatedIfNoHttpMethods() + { + var operation = GetOpenApiOperation(() => { }, "/", Array.Empty()); + + Assert.Null(operation); + } + + [Fact] + public void UsesDeclaringTypeAsOperationTags() + { + var operation = GetOpenApiOperation(TestAction); + var declaringTypeName = typeof(OpenApiOperationGeneratorTests).Name; + + var tag = Assert.Single(operation.Tags); + + Assert.Equal(declaringTypeName, tag.Name); + + } + + [Fact] + public void UsesApplicationNameAsOperationTagsIfNoDeclaringType() + { + var operation = GetOpenApiOperation(() => { }); + + var declaringTypeName = nameof(OpenApiOperationGeneratorTests); + var tag = Assert.Single(operation.Tags); + + Assert.Equal(declaringTypeName, tag.Name); + } + + [Fact] + public void AddsRequestFormatFromMetadata() + { + static void AssertCustomRequestFormat(OpenApiOperation operation) + { + var request = Assert.Single(operation.Parameters); + var content = Assert.Single(request.Content); + Assert.Equal("application/custom", content.Key); + } + + AssertCustomRequestFormat(GetOpenApiOperation( + [Consumes("application/custom")] (InferredJsonClass fromBody) => { })); + + AssertCustomRequestFormat(GetOpenApiOperation( + [Consumes("application/custom")] ([FromBody] int fromBody) => { })); + } + + [Fact] + public void AddsMultipleRequestFormatsFromMetadata() + { + var operation = GetOpenApiOperation( + [Consumes("application/custom0", "application/custom1")] (InferredJsonClass fromBody) => { }); + + var request = Assert.Single(operation.Parameters); + + Assert.Equal(2, request.Content.Count); + Assert.Equal(new[] { "application/custom0", "application/custom1" }, request.Content.Keys); + } + + [Fact] + public void AddsMultipleRequestFormatsFromMetadataWithRequestTypeAndOptionalBodyParameter() + { + var operation = GetOpenApiOperation( + [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1", IsOptional = true)] () => { }); + var request = operation.RequestBody; + Assert.NotNull(request); + + Assert.Equal(2, request.Content.Count); + + Assert.Equal("object", request.Content.First().Value.Schema.Type); + Assert.Equal("object", request.Content.Last().Value.Schema.Type); + Assert.False(request.Required); + } + +#nullable enable + + [Fact] + public void AddsMultipleRequestFormatsFromMetadataWithRequiredBodyParameter() + { + var operation = GetOpenApiOperation( + [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1", IsOptional = false)] (InferredJsonClass fromBody) => { }); + + var request = operation.RequestBody; + Assert.NotNull(request); + + Assert.Equal("object", request.Content.First().Value.Schema.Type); + Assert.True(request.Required); + } + +#nullable disable + + [Fact] + public void AddsJsonResponseFormatWhenFromBodyInferred() + { + static void AssertJsonResponse(OpenApiOperation operation, string expectedType) + { + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var formats = Assert.Single(response.Value.Content); + Assert.Equal(expectedType, formats.Value.Schema.Type); + + Assert.Equal("application/json", formats.Key); + } + + AssertJsonResponse(GetOpenApiOperation(() => new InferredJsonClass()), "object"); + AssertJsonResponse(GetOpenApiOperation(() => (IInferredJsonInterface)null), "object"); + } + + [Fact] + public void AddsTextResponseFormatWhenFromBodyInferred() + { + var operation = GetOpenApiOperation(() => "foo"); + + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var formats = Assert.Single(response.Value.Content); + Assert.Equal("string", formats.Value.Schema.Type); + Assert.Equal("text/plain", formats.Key); + } + + [Fact] + public void AddsNoResponseFormatWhenItCannotBeInferredAndTheresNoMetadata() + { + static void AssertVoid(OpenApiOperation operation) + { + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Empty(response.Value.Content); + } + + AssertVoid(GetOpenApiOperation(() => { })); + AssertVoid(GetOpenApiOperation(() => Task.CompletedTask)); + AssertVoid(GetOpenApiOperation(() => new ValueTask())); + } + + [Fact] + public void AddsMultipleResponseFormatsFromMetadataWithPoco() + { + var operation = GetOpenApiOperation( + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => new InferredJsonClass()); + + var responses = operation.Responses; + + Assert.Equal(2, responses.Count); + + var createdResponseType = responses["201"]; + var content = Assert.Single(createdResponseType.Content); + + Assert.NotNull(createdResponseType); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("application/json", createdResponseType.Content.Keys.First()); + + var badRequestResponseType = responses["400"]; + + Assert.NotNull(badRequestResponseType); + Assert.Equal("object", badRequestResponseType.Content.Values.First().Schema.Type); + Assert.Equal("application/json", badRequestResponseType.Content.Keys.First()); + } + + [Fact] + public void AddsMultipleResponseFormatsFromMetadataWithIResult() + { + var operation = GetOpenApiOperation( + [ProducesResponseType(typeof(InferredJsonClass), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => Results.Ok(new InferredJsonClass())); + + Assert.Equal(2, operation.Responses.Count); + + var createdResponseType = operation.Responses["201"]; + var createdResponseContent = Assert.Single(createdResponseType.Content); + + Assert.NotNull(createdResponseType); + Assert.Equal("object", createdResponseContent.Value.Schema.Type); + Assert.Equal("application/json", createdResponseContent.Key); + + var badRequestResponseType = operation.Responses["400"]; + + Assert.NotNull(badRequestResponseType); + Assert.Empty(badRequestResponseType.Content); + } + + [Fact] + public void AddsFromRouteParameterAsPath() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("number", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + + AssertPathParameter(GetOpenApiOperation((int foo) => { }, "/{foo}")); + AssertPathParameter(GetOpenApiOperation(([FromRoute] int foo) => { })); + } + + [Fact] + public void AddsFromRouteParameterAsPathWithCustomClassWithTryParse() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("object", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + AssertPathParameter(GetOpenApiOperation((TryParseStringRecord foo) => { }, pattern: "/{foo}")); + } + + [Fact] + public void AddsFromRouteParameterAsPathWithNullablePrimitiveType() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("number", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + + AssertPathParameter(GetOpenApiOperation((int? foo) => { }, "/{foo}")); + AssertPathParameter(GetOpenApiOperation(([FromRoute] int? foo) => { })); + } + + [Fact] + public void AddsFromRouteParameterAsPathWithStructTypeWithTryParse() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("object", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + AssertPathParameter(GetOpenApiOperation((TryParseStringRecordStruct foo) => { }, pattern: "/{foo}")); + } + + [Fact] + public void AddsFromQueryParameterAsQuery() + { + static void AssertQueryParameter(OpenApiOperation operation, string type) + { + var param = Assert.Single(operation.Parameters); ; + Assert.Equal(type, param.Schema.Type); + Assert.Equal(ParameterLocation.Query, param.In); + } + + AssertQueryParameter(GetOpenApiOperation((int foo) => { }, "/"), "number"); + AssertQueryParameter(GetOpenApiOperation(([FromQuery] int foo) => { }), "number"); + AssertQueryParameter(GetOpenApiOperation(([FromQuery] TryParseStringRecordStruct foo) => { }), "object"); + AssertQueryParameter(GetOpenApiOperation((int[] foo) => { }, "/"), "array"); + AssertQueryParameter(GetOpenApiOperation((string[] foo) => { }, "/"), "array"); + AssertQueryParameter(GetOpenApiOperation((StringValues foo) => { }, "/"), "object"); + AssertQueryParameter(GetOpenApiOperation((TryParseStringRecordStruct[] foo) => { }, "/"), "array"); + } + + [Theory] + [InlineData("Put")] + [InlineData("Post")] + public void BodyIsInferredForArraysInsteadOfQuerySomeHttpMethods(string httpMethod) + { + static void AssertBody(OpenApiOperation operation, string expectedType) + { + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal(expectedType, content.Value.Schema.Type); + } + + AssertBody(GetOpenApiOperation((int[] foo) => { }, "/", httpMethods: new[] { httpMethod }), "array"); + AssertBody(GetOpenApiOperation((string[] foo) => { }, "/", httpMethods: new[] { httpMethod }), "array"); + AssertBody(GetOpenApiOperation((TryParseStringRecordStruct[] foo) => { }, "/", httpMethods: new[] { httpMethod }), "array"); + } + + [Fact] + public void AddsFromHeaderParameterAsHeader() + { + var operation = GetOpenApiOperation(([FromHeader] int foo) => { }); + var param = Assert.Single(operation.Parameters); + + Assert.Equal("number", param.Schema.Type); + Assert.Equal(ParameterLocation.Header, param.In); + } + + [Fact] + public void DoesNotAddFromServiceParameterAsService() + { + Assert.Empty(GetOpenApiOperation((IInferredServiceInterface foo) => { }).Parameters); + Assert.Empty(GetOpenApiOperation(([FromServices] int foo) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((HttpContext context) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((HttpRequest request) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((HttpResponse response) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((ClaimsPrincipal user) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((CancellationToken token) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((BindAsyncRecord context) => { }).Parameters); + } + + [Fact] + public void AddsBodyParameterInTheParameterDescription() + { + static void AssertBodyParameter(OpenApiOperation operation, string expectedName, string expectedType) + { + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal(expectedType, content.Value.Schema.Type); + } + + AssertBodyParameter(GetOpenApiOperation((InferredJsonClass foo) => { }), "foo", "object"); + AssertBodyParameter(GetOpenApiOperation(([FromBody] int bar) => { }), "bar", "number"); + } + +#nullable enable + + [Fact] + public void AddsMultipleParameters() + { + var operation = GetOpenApiOperation(([FromRoute] int foo, int bar, InferredJsonClass fromBody) => { }); + Assert.Equal(3, operation.Parameters.Count); + + var fooParam = operation.Parameters[0]; + Assert.Equal("foo", fooParam.Name); + Assert.Equal("number", fooParam.Schema.Type); + Assert.Equal(ParameterLocation.Path, fooParam.In); + Assert.True(fooParam.Required); + + var barParam = operation.Parameters[1]; + Assert.Equal("bar", barParam.Name); + Assert.Equal("number", barParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, barParam.In); + Assert.True(barParam.Required); + + var fromBodyParam = operation.RequestBody; + Assert.Equal("object", fromBodyParam.Content.First().Value.Schema.Type); + Assert.True(fromBodyParam.Required); + } + +#nullable disable + + [Fact] + public void TestParameterIsRequired() + { + var operation = GetOpenApiOperation(([FromRoute] int foo, int? bar) => { }); + Assert.Equal(2, operation.Parameters.Count); + + var fooParam = operation.Parameters[0]; + Assert.Equal("foo", fooParam.Name); + Assert.Equal("number", fooParam.Schema.Type); + Assert.Equal(ParameterLocation.Path, fooParam.In); + Assert.True(fooParam.Required); + + var barParam = operation.Parameters[1]; + Assert.Equal("bar", barParam.Name); + Assert.Equal("number", barParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, barParam.In); + Assert.False(barParam.Required); + } + + [Fact] + public void TestParameterIsRequiredForObliviousNullabilityContext() + { + // In an oblivious nullability context, reference type parameters without + // annotations are optional. Value type parameters are always required. + var operation = GetOpenApiOperation((string foo, int bar) => { }); + Assert.Equal(2, operation.Parameters.Count); + + var fooParam = operation.Parameters[0]; + Assert.Equal("string", fooParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, fooParam.In); + Assert.False(fooParam.Required); + + var barParam = operation.Parameters[1]; + Assert.Equal("number", barParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, barParam.In); + Assert.True(barParam.Required); + } + + [Fact] + public void RespectProducesProblemMetadata() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] { + new ProducesResponseTypeMetadata(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/json+problem") }); + + // Assert + var responses = Assert.Single(operation.Responses); + var content = Assert.Single(responses.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + } + + [Fact] + public void RespectsProducesWithGroupNameExtensionMethod() + { + // Arrange + var endpointGroupName = "SomeEndpointGroupName"; + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new object[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new EndpointNameMetadata(endpointGroupName) + }); + + var responses = Assert.Single(operation.Responses); + var content = Assert.Single(responses.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + } + + [Fact] + public void RespectsExcludeFromDescription() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new object[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new ExcludeFromDescriptionAttribute() + }); + + Assert.Null(operation); + } + + [Fact] + public void HandlesProducesWithProducesProblem() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new ProducesResponseTypeMetadata(typeof(HttpValidationProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json"), + new ProducesResponseTypeMetadata(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json"), + new ProducesResponseTypeMetadata(typeof(ProblemDetails), StatusCodes.Status409Conflict, "application/problem+json") + }); + var responses = operation.Responses; + + // Assert + Assert.Collection( + responses.OrderBy(response => response.Key), + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("200", responseType.Key); + Assert.Equal("application/json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("400", responseType.Key); + Assert.Equal("application/problem+json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("404", responseType.Key); + Assert.Equal("application/problem+json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("409", responseType.Key); + Assert.Equal("application/problem+json", content.Key); + }); + } + + [Fact] + public void HandleMultipleProduces() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status201Created, "application/json") + }); + + var responses = operation.Responses; + + // Assert + Assert.Collection( + responses.OrderBy(response => response.Key), + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("200", responseType.Key); + Assert.Equal("application/json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("201", responseType.Key); + Assert.Equal("application/json", content.Key); + }); + } + + [Fact] + public void HandleAcceptsMetadata() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] + { + new AcceptsMetadata(typeof(string), true, new string[] { "application/json", "application/xml"}) + }); + + var requestBody = operation.RequestBody; + + // Assert + Assert.Collection( + requestBody.Content, + parameter => + { + Assert.Equal("application/json", parameter.Key); + }, + parameter => + { + Assert.Equal("application/xml", parameter.Key); + }); + } + + [Fact] + public void HandleAcceptsMetadataWithTypeParameter() + { + // Arrange + var operation = GetOpenApiOperation((InferredJsonClass inferredJsonClass) => "", + additionalMetadata: new[] + { + new AcceptsMetadata(typeof(InferredJsonClass), true, new string[] { "application/json"}) + }); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + +#nullable enable + + [Fact] + public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() + { + // Arrange + var operation = GetOpenApiOperation((InferredJsonClass inferredJsonClass) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/json", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForOptionalBodyParameter() + { + // Arrange + var operation = GetOpenApiOperation((InferredJsonClass? inferredJsonClass) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/json", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + + [Fact] + public void HandleIAcceptsMetadataWithConsumesAttributeAndInferredOptionalFromBodyType() + { + // Arrange + var operation = GetOpenApiOperation([Consumes("application/xml")] (InferredJsonClass? inferredJsonClass) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/xml", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForRequiredFormFileParameter() + { + // Arrange + var operation = GetOpenApiOperation((IFormFile inferredFormFile) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForOptionalFormFileParameter() + { + // Arrange + var operation = GetOpenApiOperation((IFormFile? inferredFormFile) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + + [Fact] + public void AddsMultipartFormDataRequestFormatWhenFormFileSpecified() + { + // Arrange + var operation = GetOpenApiOperation((IFormFile file) => Results.NoContent()); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + + [Fact] + public void HasMultipleRequestFormatsWhenFormFileSpecifiedWithConsumesAttribute() + { + var operation = GetOpenApiOperation( + [Consumes("application/custom0", "application/custom1")] (IFormFile file) => Results.NoContent()); + + var requestBody = operation.RequestBody; + var content = requestBody.Content; + + Assert.Equal(2, content.Count); + + var requestFormat0 = content["application/custom0"]; + Assert.NotNull(requestFormat0); + + var requestFormat1 = content["application/custom1"]; + Assert.NotNull(requestFormat1); + } + + [Fact] + public void TestIsRequiredFromFormFile() + { + var operation0 = GetOpenApiOperation((IFormFile fromFile) => { }); + var operation1 = GetOpenApiOperation((IFormFile? fromFile) => { }); + Assert.NotNull(operation0.RequestBody); + Assert.NotNull(operation1.RequestBody); + + var fromFileParam0 = operation0.RequestBody; + Assert.Equal("object", fromFileParam0.Content.Values.Single().Schema.Type); + Assert.True(fromFileParam0.Required); + + var fromFileParam1 = operation1.RequestBody; + Assert.Equal("object", fromFileParam1.Content.Values.Single().Schema.Type); + Assert.False(fromFileParam1.Required); + } + + [Fact] + public void AddsFromFormParameterAsFormFile() + { + static void AssertFormFileParameter(OpenApiOperation operation, string expectedType, string expectedName) + { + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal(expectedType, content.Value.Schema.Type); + Assert.Equal("multipart/form-data", content.Key); + } + + AssertFormFileParameter(GetOpenApiOperation((IFormFile file) => { }), "object", "file"); + AssertFormFileParameter(GetOpenApiOperation(([FromForm(Name = "file_name")] IFormFile file) => { }), "object", "file_name"); + } + + [Fact] + public void AddsMultipartFormDataResponseFormatWhenFormFileCollectionSpecified() + { + AssertFormFileCollection((IFormFileCollection files) => Results.NoContent(), "files"); + AssertFormFileCollection(([FromForm] IFormFileCollection uploads) => Results.NoContent(), "uploads"); + + static void AssertFormFileCollection(Delegate handler, string expectedName) + { + // Arrange + var operation = GetOpenApiOperation(handler); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + } + +#nullable restore + + [Fact] + public void HandlesEndpointWithDescriptionAndSummary_WithAttributes() + { + var operation = GetOpenApiOperation( + [EndpointSummary("A summary")][EndpointDescription("A description")] (int id) => ""); + + // Assert + Assert.Equal("A description", operation.Description); + Assert.Equal("A summary", operation.Summary); + } + + private static OpenApiOperation GetOpenApiOperation( + Delegate action, + string pattern = null, + IEnumerable httpMethods = null, + string displayName = null, + object[] additionalMetadata = null) + { + var methodInfo = action.Method; + var attributes = methodInfo.GetCustomAttributes(); + + var httpMethodMetadata = new HttpMethodMetadata(httpMethods ?? new[] { "GET" }); + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiOperationGeneratorTests) }; + var metadataItems = new List(attributes) { methodInfo, httpMethodMetadata }; + metadataItems.AddRange(additionalMetadata ?? Array.Empty()); + var endpointMetadata = new EndpointMetadataCollection(metadataItems.ToArray()); + var routePattern = RoutePatternFactory.Parse(pattern ?? "/"); + + var generator = new OpenApiGenerator( + hostEnvironment, + new ServiceProviderIsService()); + + return generator.GetOpenApiOperation(methodInfo, endpointMetadata, routePattern); + } + + private static void TestAction() + { + } + + // Shared with OpenApiRouteHandlerExtensionsTests + internal class ServiceProviderIsService : IServiceProviderIsService + { + public bool IsService(Type serviceType) => serviceType == typeof(IInferredServiceInterface); + } + + internal class HostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } + public string ApplicationName { get; set; } + public string ContentRootPath { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + } + + private class InferredJsonClass + { + } + + private interface IInferredJsonInterface + { + } + + private record TryParseStringRecord(int Value) + { + public static bool TryParse(string value, out TryParseStringRecord result) => + throw new NotImplementedException(); + } + + private record struct TryParseStringRecordStruct(int Value) + { + public static bool TryParse(string value, out TryParseStringRecordStruct result) => + throw new NotImplementedException(); + } + + private interface IInferredServiceInterface + { + } + + private record BindAsyncRecord(int Value) + { + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => + throw new NotImplementedException(); + public static bool TryParse(string value, out BindAsyncRecord result) => + throw new NotImplementedException(); + } +} diff --git a/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs b/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs new file mode 100644 index 000000000000..97be66d20a46 --- /dev/null +++ b/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.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 Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests; + +namespace Microsoft.AspNetCore.OpenApi.Tests; + +public class OpenApiRouteHandlerBuilderExtensionTests +{ + [Fact] + public void WithOpenApi_CanSetOperationInMetadata() + { + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiOperationGeneratorTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = new ServiceCollection() + .AddSingleton(serviceProviderIsService) + .AddSingleton(hostEnvironment) + .BuildServiceProvider(); + + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + string GetString() => "Foo"; + _ = builder.MapDelete("/", GetString).WithOpenApi(); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var operation = endpoint.Metadata.GetMetadata(); + Assert.NotNull(operation); + Assert.Single(operation.Responses); // Sanity check generated operation + } + + [Fact] + public void WithOpenApi_CanSetOperationInMetadataWithOverride() + { + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiOperationGeneratorTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = new ServiceCollection() + .AddSingleton(serviceProviderIsService) + .AddSingleton(hostEnvironment) + .BuildServiceProvider(); + + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + string GetString() => "Foo"; + _ = builder.MapDelete("/", GetString).WithOpenApi(generatedOperation => new OpenApiOperation()); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var operation = endpoint.Metadata.GetMetadata(); + Assert.NotNull(operation); + Assert.Empty(operation.Responses); + } + + private ModelEndpointDataSource GetBuilderEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder) + { + return Assert.IsType(Assert.Single(endpointRouteBuilder.DataSources)); + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj b/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj index bd9def6c3dc2..2dc413643421 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj +++ b/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj @@ -40,6 +40,7 @@ + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in b/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in index 4b3f835282a0..5b949b47fba0 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in +++ b/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in @@ -14,6 +14,7 @@ + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs index 2329d42dafe3..b6ec2e136ef7 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs @@ -3,6 +3,9 @@ #endif using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +#if (EnableOpenAPI) +using Microsoft.AspNetCore.OpenApi; +#endif #if (GenerateGraph) using Graph = Microsoft.Graph; #endif @@ -134,6 +137,7 @@ #if (EnableOpenAPI) }) .WithName("GetWeatherForecast") +.WithOpenApi() .RequireAuthorization(); #else }) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs index 8b8e2b81afd0..1ef820c6fb6a 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs @@ -1,3 +1,7 @@ +#if (EnableOpenAPI) +using Microsoft.AspNetCore.OpenApi; + +#endif #if (WindowsAuth) using Microsoft.AspNetCore.Authentication.Negotiate; @@ -57,7 +61,8 @@ return forecast; #if (EnableOpenAPI) }) -.WithName("GetWeatherForecast"); +.WithName("GetWeatherForecast") +.WithOpenApi(); #else }); #endif