Browse Source

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
Safia Abdalla 3 years ago
parent
commit
46554c9183
27 changed files with 1620 additions and 1 deletions
  1. 41 0
      AspNetCore.sln
  2. 2 0
      eng/Build.props
  3. 1 0
      eng/Dependencies.props
  4. 1 0
      eng/ProjectReferences.props
  5. 1 0
      eng/Versions.props
  6. 5 0
      src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs
  7. 2 0
      src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
  8. 1 0
      src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs
  9. 1 0
      src/Http/Routing/src/Properties/AssemblyInfo.cs
  10. 14 0
      src/OpenApi/OpenApi.slnf
  11. 5 0
      src/OpenApi/README.md
  12. 3 0
      src/OpenApi/build.cmd
  13. 7 0
      src/OpenApi/build.sh
  14. 28 0
      src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
  15. 474 0
      src/OpenApi/src/OpenApiGenerator.cs
  16. 82 0
      src/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs
  17. 1 0
      src/OpenApi/src/PublicAPI.Shipped.txt
  18. 4 0
      src/OpenApi/src/PublicAPI.Unshipped.txt
  19. 44 0
      src/OpenApi/src/SchemaGenerator.cs
  20. 3 0
      src/OpenApi/startvs.cmd
  21. 19 0
      src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj
  22. 804 0
      src/OpenApi/test/OpenApiGeneratorTests.cs
  23. 65 0
      src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs
  24. 1 0
      src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj
  25. 1 0
      src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in
  26. 4 0
      src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs
  27. 6 1
      src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs

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

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

+ 1 - 0
eng/Dependencies.props

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

+ 1 - 0
eng/ProjectReferences.props

@@ -159,5 +159,6 @@
     <ProjectReferenceProvider Include="Microsoft.Extensions.Features" ProjectPath="$(RepoRoot)src\Extensions\Features\src\Microsoft.Extensions.Features.csproj" />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Grpc.JsonTranscoding" ProjectPath="$(RepoRoot)src\Grpc\JsonTranscoding\src\Microsoft.AspNetCore.Grpc.JsonTranscoding\Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj" />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Grpc.Swagger" ProjectPath="$(RepoRoot)src\Grpc\JsonTranscoding\src\Microsoft.AspNetCore.Grpc.Swagger\Microsoft.AspNetCore.Grpc.Swagger.csproj" />
+    <ProjectReferenceProvider Include="Microsoft.AspNetCore.OpenApi" ProjectPath="$(RepoRoot)src\OpenApi\src\Microsoft.AspNetCore.OpenApi.csproj" />
   </ItemGroup>
 </Project>

+ 1 - 0
eng/Versions.props

@@ -282,6 +282,7 @@
     <XUnitRunnerVisualStudioVersion>2.4.3</XUnitRunnerVisualStudioVersion>
     <MicrosoftDataSqlClientVersion>4.0.1</MicrosoftDataSqlClientVersion>
     <MicrosoftAspNetCoreAppVersion>6.0.0-preview.3.21167.1</MicrosoftAspNetCoreAppVersion>
+    <MicrosoftOpenApiVersion>1.2.3</MicrosoftOpenApiVersion>
   </PropertyGroup>
   <!-- Restore feeds -->
   <PropertyGroup Label="Restore feeds">

+ 5 - 0
src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs

@@ -25,6 +25,11 @@ public abstract class EndpointBuilder
     /// </summary>
     public IList<object> Metadata { get; } = new List<object>();
 
+    /// <summary>
+    /// Gets the <see cref="IServiceProvider"/> associated with the endpoint.
+    /// </summary>
+    public IServiceProvider? ServiceProvider { get; set; }
+
     /// <summary>
     /// Creates an instance of <see cref="Endpoint"/> from the <see cref="EndpointBuilder"/>.
     /// </summary>

+ 2 - 0
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>() -> T!
 Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask<object?>
 Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata

+ 1 - 0
src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs

@@ -507,6 +507,7 @@ public static class EndpointRouteBuilderExtensions
             defaultOrder)
         {
             DisplayName = pattern.RawText ?? pattern.DebuggerToString(),
+            ServiceProvider = endpoints.ServiceProvider,
         };
 
         // Methods defined in a top-level program are generated as statics so the delegate

+ 1 - 0
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")]

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

+ 5 - 0
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.
+

+ 3 - 0
src/OpenApi/build.cmd

@@ -0,0 +1,3 @@
+@ECHO OFF
+SET RepoRoot=%~dp0..\..
+%RepoRoot%\eng\build.cmd -projects %~dp0**\*.*proj %*

+ 7 - 0
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" "$@"

+ 28 - 0
src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj

@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <PackageTags>aspnetcore;openapi</PackageTags>
+    <Description>Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations.</Description>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.OpenApi" />
+    <Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
+    <Reference Include="Microsoft.AspNetCore.Routing" />
+    <Reference Include="Microsoft.AspNetCore.Mvc.Core" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)RoslynUtils\TypeHelper.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)ParameterBindingMethodCache.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)TypeNameHelper\TypeNameHelper.cs" LinkBase="Shared" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Tests" />
+  </ItemGroup>
+
+</Project>

+ 474 - 0
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;
+
+/// <summary>
+/// Defines a set of methods for generating OpenAPI definitions for endpoints.
+/// </summary>
+internal class OpenApiGenerator
+{
+    private readonly IHostEnvironment? _environment;
+    private readonly IServiceProviderIsService? _serviceProviderIsService;
+
+    internal static readonly ParameterBindingMethodCache ParameterBindingMethodCache = new();
+
+    /// <summary>
+    /// Creates an <see cref="OpenApiGenerator" /> instance given an <see cref="IHostEnvironment" />
+    /// and an <see cref="IServiceProviderIsService" /> instance.
+    /// </summary>
+    /// <param name="environment">The host environment.</param>
+    /// <param name="serviceProviderIsService">The service to determine if the type is available from the <see cref="IServiceProvider"/>.</param>
+    internal OpenApiGenerator(
+        IHostEnvironment? environment,
+        IServiceProviderIsService? serviceProviderIsService)
+    {
+        _environment = environment;
+        _serviceProviderIsService = serviceProviderIsService;
+    }
+
+    /// <summary>
+    /// Generates an <see cref="OpenApiPathItem"/> for a given <see cref="Endpoint" />.
+    /// </summary>
+    /// <param name="methodInfo">The <see cref="MethodInfo"/> associated with the route handler of the endpoint.</param>
+    /// <param name="metadata">The endpoint <see cref="EndpointMetadataCollection"/>.</param>
+    /// <param name="pattern">The route pattern.</param>
+    /// <returns>An <see cref="OpenApiPathItem"/> annotation derived from the given inputs.</returns>
+    internal OpenApiOperation? GetOpenApiOperation(
+        MethodInfo methodInfo,
+        EndpointMetadataCollection metadata,
+        RoutePattern pattern)
+    {
+        if (metadata.GetMetadata<IHttpMethodMetadata>() is { } httpMethodMetadata &&
+            httpMethodMetadata.HttpMethods.SingleOrDefault() is { } method &&
+            metadata.GetMetadata<IExcludeFromDescriptionMetadata>() 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<IEndpointNameMetadata>()?.EndpointName,
+            Summary = metadata.GetMetadata<IEndpointSummaryMetadata>()?.Summary,
+            Description = metadata.GetMetadata<IEndpointDescriptionMetadata>()?.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<ProducesErrorResponseTypeAttribute>();
+        var defaultErrorType = errorMetadata?.Type;
+
+        var responseProviderMetadata = metadata.GetOrderedMetadata<IApiResponseMetadataProvider>();
+        var producesResponseMetadata = metadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
+
+        var eligibileAnnotations = new Dictionary<int, (Type?, MediaTypeCollection)>();
+
+        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<string, OpenApiMediaType>();
+
+            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<int, (Type?, MediaTypeCollection)> 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<IAcceptsMetadata>();
+        var requestBodyContent = new Dictionary<string, OpenApiMediaType>();
+        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<IFromFormMetadata>().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<IFromBodyMetadata>().SingleOrDefault()?.AllowEmpty ?? false;
+            var isOptional = requestBodyParameter.HasDefaultValue
+                || nullability.ReadState != NullabilityState.NotNull
+                || allowEmpty;
+
+            return new OpenApiRequestBody
+            {
+                Required = !isOptional,
+                Content = requestBodyContent
+            };
+        }
+
+        return null;
+    }
+
+    private List<OpenApiTag> GetOperationTags(MethodInfo methodInfo, EndpointMetadataCollection metadata)
+    {
+        var tags = metadata.GetMetadata<ITagsMetadata>();
+        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<OpenApiTag>() { new OpenApiTag() { Name = controllerName } };
+    }
+
+    private List<OpenApiParameter> GetOpenApiParameters(MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern, bool disableInferredBody)
+    {
+        var parameters = methodInfo.GetParameters();
+        var openApiParameters = new List<OpenApiParameter>();
+
+        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<string, OpenApiMediaType> GetOpenApiParameterContent(EndpointMetadataCollection metadata)
+    {
+        var openApiParameterContent = new Dictionary<string, OpenApiMediaType>();
+        var acceptsMetadata = metadata.GetMetadata<IAcceptsMetadata>();
+        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<IFromRouteMetadata>().FirstOrDefault() is { } routeAttribute)
+        {
+            return (false, ParameterLocation.Path);
+        }
+        else if (attributes.OfType<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute)
+        {
+            return (false, ParameterLocation.Query);
+        }
+        else if (attributes.OfType<IFromHeaderMetadata>().FirstOrDefault() is { } headerAttribute)
+        {
+            return (false, ParameterLocation.Header);
+        }
+        else if (attributes.OfType<IFromBodyMetadata>().FirstOrDefault() is { } fromBodyAttribute)
+        {
+            return (true, null);
+        }
+        else if (attributes.OfType<IFromFormMetadata>().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);
+        }
+    }
+}

+ 82 - 0
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;
+
+/// <summary>
+/// Extension methods for annotating OpenAPI descriptions on an <see cref="Endpoint" />.
+/// </summary>
+public static class OpenApiRouteHandlerBuilderExtensions
+{
+    /// <summary>
+    /// Adds an OpenAPI annotation to <see cref="Endpoint.Metadata" /> associated
+    /// with the current endpoint.
+    /// </summary>
+    /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
+    /// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
+    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;
+    }
+
+    /// <summary>
+    /// Adds an OpenAPI annotation to <see cref="Endpoint.Metadata" /> associated
+    /// with the current endpoint and modifies it with the given <paramref name="configureOperation"/>.
+    /// </summary>
+    /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
+    /// <param name="configureOperation">An <see cref="Func{T, TResult}"/> that returns a new OpenAPI annotation given a generated operation.</param>
+    /// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
+    public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder, Func<OpenApiOperation, OpenApiOperation> 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<MethodInfo>().SingleOrDefault();
+        var serviceProvider = routeEndpointBuilder.ServiceProvider;
+
+        if (methodInfo == null || serviceProvider == null)
+        {
+            return null;
+        }
+
+        var hostEnvironment = serviceProvider.GetService<IHostEnvironment>();
+        var serviceProviderIsService = serviceProvider.GetService<IServiceProviderIsService>();
+        var generator = new OpenApiGenerator(hostEnvironment, serviceProviderIsService);
+        return generator.GetOpenApiOperation(methodInfo, metadata, pattern);
+    }
+}

+ 1 - 0
src/OpenApi/src/PublicAPI.Shipped.txt

@@ -0,0 +1 @@
+#nullable enable

+ 4 - 0
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<Microsoft.OpenApi.Models.OpenApiOperation!, Microsoft.OpenApi.Models.OpenApiOperation!>! configureOperation) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder!

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

+ 3 - 0
src/OpenApi/startvs.cmd

@@ -0,0 +1,3 @@
+@ECHO OFF
+
+%~dp0..\..\startvs.cmd %~dp0OpenApi.slnf

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

@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Http" />
+    <Reference Include="Microsoft.AspNetCore.Http.Results" />
+    <Reference Include="Microsoft.Extensions.DependencyInjection" />
+    <Reference Include="Microsoft.OpenApi" />
+    <Reference Include="Microsoft.AspNetCore.Routing" />
+    <Reference Include="Microsoft.Extensions.Hosting" />
+    <Reference Include="Microsoft.AspNetCore.Mvc.Core" />
+    <Reference Include="Microsoft.Extensions.Hosting.Abstractions" />
+    <Reference Include="Microsoft.AspNetCore.OpenApi" />
+  </ItemGroup>
+
+</Project>

+ 804 - 0
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<string>());
+
+        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<string> 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<object>(attributes) { methodInfo, httpMethodMetadata };
+        metadataItems.AddRange(additionalMetadata ?? Array.Empty<object>());
+        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<BindAsyncRecord> BindAsync(HttpContext context, ParameterInfo parameter) =>
+            throw new NotImplementedException();
+        public static bool TryParse(string value, out BindAsyncRecord result) =>
+            throw new NotImplementedException();
+    }
+}

+ 65 - 0
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<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(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<OpenApiOperation>();
+        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<IServiceProviderIsService>(serviceProviderIsService)
+            .AddSingleton<IHostEnvironment>(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<OpenApiOperation>();
+        Assert.NotNull(operation);
+        Assert.Empty(operation.Responses);
+    }
+
+    private ModelEndpointDataSource GetBuilderEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder)
+    {
+        return Assert.IsType<ModelEndpointDataSource>(Assert.Single(endpointRouteBuilder.DataSources));
+    }
+}

+ 1 - 0
src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj

@@ -40,6 +40,7 @@
     <PackageVersionVariableReference Include="$(RepoRoot)src\Identity\ApiAuthorization.IdentityServer\src\Microsoft.AspNetCore.ApiAuthorization.IdentityServer.csproj" />
     <PackageVersionVariableReference Include="$(RepoRoot)src\Middleware\Diagnostics.EntityFrameworkCore\src\Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj" />
     <PackageVersionVariableReference Include="$(RepoRoot)src\Mvc\Mvc.Razor.RuntimeCompilation\src\Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.csproj" />
+    <PackageVersionVariableReference Include="$(RepoRoot)src\OpenApi\src\Microsoft.AspNetCore.OpenApi.csproj" />
     <PackageVersionVariableReference Include="$(RepoRoot)src\Security\Authentication\JwtBearer\src\Microsoft.AspNetCore.Authentication.JwtBearer.csproj" />
     <PackageVersionVariableReference Include="$(RepoRoot)src\Security\Authentication\Negotiate\src\Microsoft.AspNetCore.Authentication.Negotiate.csproj" />
     <PackageVersionVariableReference Include="$(RepoRoot)src\Security\Authentication\OpenIdConnect\src\Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj" />

+ 1 - 0
src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in

@@ -14,6 +14,7 @@
     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="${MicrosoftAspNetCoreAuthenticationJwtBearerVersion}" Condition="'$(OrganizationalAuth)' == 'True' OR '$(IndividualB2CAuth)' == 'True'" />
     <PackageReference Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="${MicrosoftAspNetCoreAuthenticationNegotiateVersion}" Condition="'$(WindowsAuth)' == 'True'" />
     <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="${MicrosoftAspNetCoreAuthenticationOpenIdConnectVersion}" Condition="'$(OrganizationalAuth)' == 'True' OR '$(IndividualB2CAuth)' == 'True'" />
+    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="${MicrosoftAspNetCoreOpenApiVersion}" Condition="'$(EnableOpenAPI)' == 'True'" />
     <PackageReference Include="Microsoft.Identity.Web" Version="${MicrosoftIdentityWebVersion}" Condition="'$(OrganizationalAuth)' == 'True' OR '$(IndividualB2CAuth)' == 'True'"/>
     <PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="${MicrosoftIdentityWebMicrosoftGraphVersion}" Condition=" '$(GenerateGraph)' == 'True' " />
     <PackageReference Include="Swashbuckle.AspNetCore" Version="${SwashbuckleAspNetCoreVersion}" Condition="'$(EnableOpenAPI)' == 'True'" />

+ 4 - 0
src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs

@@ -3,6 +3,9 @@ using System.Net.Http;
 #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 @@ app.MapGet("/weatherforecast", (HttpContext httpContext) =>
 #if (EnableOpenAPI)
 })
 .WithName("GetWeatherForecast")
+.WithOpenApi()
 .RequireAuthorization();
 #else
 })

+ 6 - 1
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 @@ app.MapGet("/weatherforecast", () =>
     return forecast;
 #if (EnableOpenAPI)
 })
-.WithName("GetWeatherForecast");
+.WithName("GetWeatherForecast")
+.WithOpenApi();
 #else
 });
 #endif