From fd19f92df938c42525c9f3a2f3acd13d7e7d88f2 Mon Sep 17 00:00:00 2001
From: Stephen Halter <halter73@gmail.com>
Date: Fri, 18 Jun 2021 12:52:34 -0700
Subject: [PATCH] Add OpenAPI/Swagger support for minimal actions (#33433)

---
 ...icrosoft.AspNetCore.Http.Extensions.csproj |   3 +-
 .../src/RequestDelegateFactory.cs             |  77 +---
 .../test/RequestDelegateFactoryTests.cs       |   2 +-
 ...malActionEndpointRouteBuilderExtensions.cs |   8 +-
 ...ctionEndpointRouteBuilderExtensionsTest.cs |  10 +-
 .../src/ApiResponseTypeProvider.cs            |  63 +--
 .../src/DefaultApiDescriptionProvider.cs      |   2 +-
 ...oApiExplorerServiceCollectionExtensions.cs |  32 ++
 .../EndpointMetadataApiDescriptionProvider.cs | 332 +++++++++++++++
 .../src/EndpointModelMetadata.cs              |  57 +++
 ...icrosoft.AspNetCore.Mvc.ApiExplorer.csproj |   4 +
 .../src/PublicAPI.Unshipped.txt               |   2 +
 ...pointMetadataApiDescriptionProviderTest.cs | 378 ++++++++++++++++++
 ...oft.AspNetCore.Mvc.ApiExplorer.Test.csproj |   1 +
 src/Mvc/Mvc.slnf                              |   1 +
 src/Shared/TryParseMethodCache.cs             |  85 ++++
 16 files changed, 947 insertions(+), 110 deletions(-)
 create mode 100644 src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs
 create mode 100644 src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs
 create mode 100644 src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs
 create mode 100644 src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs
 create mode 100644 src/Shared/TryParseMethodCache.cs

diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
index 94a3e73732a..b9961e96de2 100644
--- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
+++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
@@ -12,7 +12,8 @@
 
   <ItemGroup>
     <Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" />
-    <Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" Link="StreamCopyOperationInternal.cs" />
+    <Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" />
+    <Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
index b14ef5fb3d4..91f5660e905 100644
--- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
+++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
@@ -2,9 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
-using System.Collections.Concurrent;
 using System.Collections.Generic;
-using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Linq.Expressions;
@@ -34,7 +32,6 @@ namespace Microsoft.AspNetCore.Http
         private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
         private static readonly MethodInfo StringResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, string, Task>>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default));
         private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, object, Task>>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default));
-        private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod();
         private static readonly MethodInfo LogParameterBindingFailureMethod = GetMethodInfo<Action<HttpContext, string, string, string>>((httpContext, parameterType, parameterName, sourceValue) =>
             Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue));
 
@@ -56,8 +53,6 @@ namespace Microsoft.AspNetCore.Http
 
         private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null));
 
-        private static readonly ConcurrentDictionary<Type, MethodInfo?> TryParseMethodCache = new();
-
         /// <summary>
         /// Creates a <see cref="RequestDelegate"/> implementation for <paramref name="action"/>.
         /// </summary>
@@ -197,7 +192,7 @@ namespace Microsoft.AspNetCore.Http
         {
             if (parameter.Name is null)
             {
-                throw new InvalidOperationException("A parameter does not have a name! Was it genererated? All parameters must be named.");
+                throw new InvalidOperationException("A parameter does not have a name! Was it generated? All parameters must be named.");
             }
 
             var parameterCustomAttributes = parameter.GetCustomAttributes();
@@ -230,7 +225,7 @@ namespace Microsoft.AspNetCore.Http
             {
                 return RequestAbortedExpr;
             }
-            else if (parameter.ParameterType == typeof(string) || HasTryParseMethod(parameter))
+            else if (parameter.ParameterType == typeof(string) || TryParseMethodCache.HasTryParseMethod(parameter))
             {
                 return BindParameterFromRouteValueOrQueryString(parameter, parameter.Name, factoryContext);
             }
@@ -477,72 +472,6 @@ namespace Microsoft.AspNetCore.Http
             };
         }
 
-        private static MethodInfo GetEnumTryParseMethod()
-        {
-            var staticEnumMethods = typeof(Enum).GetMethods(BindingFlags.Public | BindingFlags.Static);
-
-            foreach (var method in staticEnumMethods)
-            {
-                if (!method.IsGenericMethod || method.Name != "TryParse" || method.ReturnType != typeof(bool))
-                {
-                    continue;
-                }
-
-                var tryParseParameters = method.GetParameters();
-
-                if (tryParseParameters.Length == 2 &&
-                    tryParseParameters[0].ParameterType == typeof(string) &&
-                    tryParseParameters[1].IsOut)
-                {
-                    return method;
-                }
-            }
-
-            throw new Exception("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) does not exist!!?!?");
-        }
-
-        // TODO: Use InvariantCulture where possible? Or is CurrentCulture fine because it's more flexible?
-        private static MethodInfo? FindTryParseMethod(Type type)
-        {
-            static MethodInfo? Finder(Type type)
-            {
-                if (type.IsEnum)
-                {
-                    return EnumTryParseMethod.MakeGenericMethod(type);
-                }
-
-                var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static);
-
-                foreach (var method in staticMethods)
-                {
-                    if (method.Name != "TryParse" || method.ReturnType != typeof(bool))
-                    {
-                        continue;
-                    }
-
-                    var tryParseParameters = method.GetParameters();
-
-                    if (tryParseParameters.Length == 2 &&
-                        tryParseParameters[0].ParameterType == typeof(string) &&
-                        tryParseParameters[1].IsOut &&
-                        tryParseParameters[1].ParameterType == type.MakeByRefType())
-                    {
-                        return method;
-                    }
-                }
-
-                return null;
-            }
-
-            return TryParseMethodCache.GetOrAdd(type, Finder);
-        }
-
-        private static bool HasTryParseMethod(ParameterInfo parameter)
-        {
-            var nonNullableParameterType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
-            return FindTryParseMethod(nonNullableParameterType) is not null;
-        }
-
         private static Expression GetValueFromProperty(Expression sourceExpression, string key)
         {
             var itemProperty = sourceExpression.Type.GetProperty("Item");
@@ -574,7 +503,7 @@ namespace Microsoft.AspNetCore.Http
             var isNotNullable = underlyingNullableType is null;
 
             var nonNullableParameterType = underlyingNullableType ?? parameter.ParameterType;
-            var tryParseMethod = FindTryParseMethod(nonNullableParameterType);
+            var tryParseMethod = TryParseMethodCache.FindTryParseMethod(nonNullableParameterType);
 
             if (tryParseMethod is null)
             {
diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
index bac3c83201d..8ecbae2c39a 100644
--- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
+++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
@@ -479,7 +479,7 @@ namespace Microsoft.AspNetCore.Routing.Internal
             var unnamedParameter = Expression.Parameter(typeof(int));
             var lambda = Expression.Lambda(Expression.Block(), unnamedParameter);
             var ex = Assert.Throws<InvalidOperationException>(() => RequestDelegateFactory.Create((Action<int>)lambda.Compile(), new EmptyServiceProvdier()));
-            Assert.Equal("A parameter does not have a name! Was it genererated? All parameters must be named.", ex.Message);
+            Assert.Equal("A parameter does not have a name! Was it generated? All parameters must be named.", ex.Message);
         }
 
         [Fact]
diff --git a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs
index fb538f4d528..9d8c6a7c33d 100644
--- a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs
+++ b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs
@@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Builder
         /// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
         /// <param name="pattern">The route pattern.</param>
         /// <param name="action">The delegate executed when the endpoint is matched.</param>
-        /// <returns>A <see cref="IEndpointConventionBuilder"/> that canaction be used to further customize the endpoint.</returns>
+        /// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns>
         public static MinimalActionEndpointConventionBuilder MapPut(
             this IEndpointRouteBuilder endpoints,
             string pattern,
@@ -166,6 +166,12 @@ namespace Microsoft.AspNetCore.Builder
                 DisplayName = pattern.RawText ?? pattern.DebuggerToString(),
             };
 
+            // REVIEW: Should we add an IActionMethodMetadata with just MethodInfo on it so we are
+            // explicit about the MethodInfo representing the "action" and not the RequestDelegate?
+
+            // Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint.
+            builder.Metadata.Add(action.Method);
+
             // Add delegate attributes as metadata
             var attributes = action.Method.GetCustomAttributes();
 
diff --git a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs
index fec8da0ce7c..f43d9d97d55 100644
--- a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs
+++ b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs
@@ -42,7 +42,9 @@ namespace Microsoft.AspNetCore.Builder
             var dataSource = Assert.Single(builder.DataSources);
             var endpoint = Assert.Single(dataSource.Endpoints);
 
-            var metadataArray = endpoint.Metadata.Where(m => m is not CompilerGeneratedAttribute).ToArray();
+            var metadataArray = endpoint.Metadata.OfType<IHttpMethodMetadata>().ToArray();
+
+            static string GetMethod(IHttpMethodMetadata metadata) => Assert.Single(metadata.HttpMethods);
 
             Assert.Equal(3, metadataArray.Length);
             Assert.Equal("ATTRIBUTE", GetMethod(metadataArray[0]));
@@ -50,12 +52,6 @@ namespace Microsoft.AspNetCore.Builder
             Assert.Equal("BUILDER", GetMethod(metadataArray[2]));
 
             Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods.Single());
-
-            string GetMethod(object metadata)
-            {
-                var httpMethodMetadata = Assert.IsAssignableFrom<IHttpMethodMetadata>(metadata);
-                return Assert.Single(httpMethodMetadata.HttpMethods);
-            }
         }
 
         [Fact]
diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
index 330ff630384..fcf7dfcf05a 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
+++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
@@ -77,13 +77,48 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
            IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
            Type? type,
            Type defaultErrorType)
+        {
+            var contentTypes = new MediaTypeCollection();
+
+            var responseTypes = ReadResponseMetadata(responseMetadataAttributes, type, defaultErrorType, contentTypes);
+
+            // Set the default status only when no status has already been set explicitly
+            if (responseTypes.Count == 0 && type != null)
+            {
+                responseTypes.Add(new ApiResponseType
+                {
+                    StatusCode = StatusCodes.Status200OK,
+                    Type = type,
+                });
+            }
+
+            if (contentTypes.Count == 0)
+            {
+                // None of the IApiResponseMetadataProvider specified a content type. This is common for actions that
+                // specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg
+                // and respond to the incoming request.
+                // Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported
+                // content types that each formatter may respond in.
+                contentTypes.Add((string)null!);
+            }
+
+            CalculateResponseFormats(responseTypes, contentTypes);
+
+            return responseTypes;
+        }
+
+        // Shared with EndpointMetadataApiDescriptionProvider
+        internal static List<ApiResponseType> ReadResponseMetadata(
+            IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
+            Type? type,
+            Type defaultErrorType,
+            MediaTypeCollection contentTypes)
         {
             var results = new Dictionary<int, ApiResponseType>();
 
             // Get the content type that the action explicitly set to support.
             // Walk through all 'filter' attributes in order, and allow each one to see or override
             // the results of the previous ones. This is similar to the execution path for content-negotiation.
-            var contentTypes = new MediaTypeCollection();
             if (responseMetadataAttributes != null)
             {
                 foreach (var metadataAttribute in responseMetadataAttributes)
@@ -105,7 +140,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
                         {
                             // 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(201, typeof(Person)] when typeof(Person) can be inferred
+                            // [ProducesResponseType(201)] instead of [ProducesResponseType(typeof(Person), 201] when typeof(Person) can be inferred
                             // from the return type.
                             apiResponseType.Type = type;
                         }
@@ -129,29 +164,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
                 }
             }
 
-            // Set the default status only when no status has already been set explicitly
-            if (results.Count == 0 && type != null)
-            {
-                results[StatusCodes.Status200OK] = new ApiResponseType
-                {
-                    StatusCode = StatusCodes.Status200OK,
-                    Type = type,
-                };
-            }
-
-            if (contentTypes.Count == 0)
-            {
-                // None of the IApiResponseMetadataProvider specified a content type. This is common for actions that
-                // specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg
-                // and respond to the incoming request.
-                // Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported
-                // content types that each formatter may respond in.
-                contentTypes.Add((string)null!);
-            }
-
-            var responseTypes = results.Values;
-            CalculateResponseFormats(responseTypes, contentTypes);
-            return responseTypes;
+            return results.Values.ToList();
         }
 
         private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes, MediaTypeCollection declaredContentTypes)
diff --git a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
index 843216b21a9..ea388cdfbb3 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
+++ b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
@@ -429,7 +429,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
             return results;
         }
 
-        private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[]? requestMetadataAttributes)
+        internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList<IApiRequestMetadataProvider>? requestMetadataAttributes)
         {
             // Walk through all 'filter' attributes in order, and allow each one to see or override
             // the results of the previous ones. This is similar to the execution path for content-negotiation.
diff --git a/src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs b/src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs
new file mode 100644
index 00000000000..0be4f4c8941
--- /dev/null
+++ b/src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs
@@ -0,0 +1,32 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+    /// <summary>
+    /// Extensions for configuring ApiExplorer using <see cref="Endpoint.Metadata"/>.
+    /// </summary>
+    public static class EndpointMetadataApiExplorerServiceCollectionExtensions
+    {
+        /// <summary>
+        /// Configures ApiExplorer using <see cref="Endpoint.Metadata"/>.
+        /// </summary>
+        /// <param name="services">The <see cref="IServiceCollection"/>.</param>
+        public static IServiceCollection AddEndpointsApiExplorer(this IServiceCollection services)
+        {
+            // Try to add default services in case MVC services aren't added.
+            services.TryAddSingleton<IActionDescriptorCollectionProvider, DefaultActionDescriptorCollectionProvider>();
+            services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>();
+
+            services.TryAddEnumerable(
+                ServiceDescriptor.Transient<IApiDescriptionProvider, EndpointMetadataApiDescriptionProvider>());
+
+            return services;
+        }
+    }
+}
diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs
new file mode 100644
index 00000000000..e4bef2e33fa
--- /dev/null
+++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs
@@ -0,0 +1,332 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Metadata;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Internal;
+
+namespace Microsoft.AspNetCore.Mvc.ApiExplorer
+{
+    internal class EndpointMetadataApiDescriptionProvider : IApiDescriptionProvider
+    {
+        private readonly EndpointDataSource _endpointDataSource;
+        private readonly IHostEnvironment _environment;
+        private readonly IServiceProviderIsService? _serviceProviderIsService;
+
+        // Executes before MVC's DefaultApiDescriptionProvider and GrpcHttpApiDescriptionProvider for no particular reason.
+        public int Order => -1100;
+
+        public EndpointMetadataApiDescriptionProvider(EndpointDataSource endpointDataSource, IHostEnvironment environment)
+            : this(endpointDataSource, environment, null)
+        {
+        }
+
+        public EndpointMetadataApiDescriptionProvider(
+            EndpointDataSource endpointDataSource,
+            IHostEnvironment environment,
+            IServiceProviderIsService? serviceProviderIsService)
+        {
+            _endpointDataSource = endpointDataSource;
+            _environment = environment;
+            _serviceProviderIsService = serviceProviderIsService;
+        }
+
+        public void OnProvidersExecuting(ApiDescriptionProviderContext context)
+        {
+            foreach (var endpoint in _endpointDataSource.Endpoints)
+            {
+                if (endpoint is RouteEndpoint routeEndpoint &&
+                    routeEndpoint.Metadata.GetMetadata<MethodInfo>() is { } methodInfo &&
+                    routeEndpoint.Metadata.GetMetadata<IHttpMethodMetadata>() is { } httpMethodMetadata)
+                {
+                    // REVIEW: Should we add an ApiDescription for endpoints without IHttpMethodMetadata? Swagger doesn't handle
+                    // a null HttpMethod even though it's nullable on ApiDescription, so we'd need to define "default" HTTP methods.
+                    // In practice, the Delegate will be called for any HTTP method if there is no IHttpMethodMetadata.
+                    foreach (var httpMethod in httpMethodMetadata.HttpMethods)
+                    {
+                        context.Results.Add(CreateApiDescription(routeEndpoint, httpMethod, methodInfo));
+                    }
+                }
+            }
+        }
+
+        public void OnProvidersExecuted(ApiDescriptionProviderContext context)
+        {
+        }
+
+        private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string httpMethod, MethodInfo methodInfo)
+        {
+            // Swashbuckle uses the "controller" name to group endpoints together.
+            // For now, put all methods defined the same declaring type together.
+            string controllerName;
+
+            if (methodInfo.DeclaringType is not null && !IsCompilerGenerated(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;
+            }
+
+            var apiDescription = new ApiDescription
+            {
+                HttpMethod = httpMethod,
+                RelativePath = routeEndpoint.RoutePattern.RawText?.TrimStart('/'),
+                ActionDescriptor = new ActionDescriptor
+                {
+                    RouteValues =
+                    {
+                        ["controller"] = controllerName,
+                    },
+                },
+            };
+
+            var hasJsonBody = false;
+
+            foreach (var parameter in methodInfo.GetParameters())
+            {
+                var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern);
+
+                if (parameterDescription.Source == BindingSource.Body)
+                {
+                    hasJsonBody = true;
+                }
+
+                apiDescription.ParameterDescriptions.Add(parameterDescription);
+            }
+
+            AddSupportedRequestFormats(apiDescription.SupportedRequestFormats, hasJsonBody, routeEndpoint.Metadata);
+            AddSupportedResponseTypes(apiDescription.SupportedResponseTypes, methodInfo.ReturnType, routeEndpoint.Metadata);
+
+            return apiDescription;
+        }
+
+        private ApiParameterDescription CreateApiParameterDescription(ParameterInfo parameter, RoutePattern pattern)
+        {
+            var (source, name) = GetBindingSourceAndName(parameter, pattern);
+
+            return new ApiParameterDescription
+            {
+                Name = name,
+                ModelMetadata = CreateModelMetadata(parameter.ParameterType),
+                Source = source,
+                DefaultValue = parameter.DefaultValue,
+                Type = parameter.ParameterType,
+            };
+        }
+
+        // TODO: Share more of this logic with RequestDelegateFactory.CreateArgument(...) using RequestDelegateFactoryUtilities
+        // which is shared source.
+        private (BindingSource, string) GetBindingSourceAndName(ParameterInfo parameter, RoutePattern pattern)
+        {
+            var attributes = parameter.GetCustomAttributes();
+
+            if (attributes.OfType<IFromRouteMetadata>().FirstOrDefault() is { } routeAttribute)
+            {
+                return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty);
+            }
+            else if (attributes.OfType<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute)
+            {
+                return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty);
+            }
+            else if (attributes.OfType<IFromHeaderMetadata>().FirstOrDefault() is { } headerAttribute)
+            {
+                return (BindingSource.Header, headerAttribute.Name ?? parameter.Name ?? string.Empty);
+            }
+            else if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType)))
+            {
+                return (BindingSource.Body, parameter.Name ?? string.Empty);
+            }
+            else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)) ||
+                     parameter.ParameterType == typeof(HttpContext) ||
+                     parameter.ParameterType == typeof(CancellationToken) ||
+                     _serviceProviderIsService?.IsService(parameter.ParameterType) == true)
+            {
+                return (BindingSource.Services, parameter.Name ?? string.Empty);
+            }
+            else if (parameter.ParameterType == typeof(string) || TryParseMethodCache.HasTryParseMethod(parameter))
+            {
+                // 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 (BindingSource.Path, name);
+                }
+                else
+                {
+                    return (BindingSource.Query, parameter.Name ?? string.Empty);
+                }
+            }
+            else
+            {
+                return (BindingSource.Body, parameter.Name ?? string.Empty);
+            }
+        }
+
+        private static void AddSupportedRequestFormats(
+            IList<ApiRequestFormat> supportedRequestFormats,
+            bool hasJsonBody,
+            EndpointMetadataCollection endpointMetadata)
+        {
+            var requestMetadata = endpointMetadata.GetOrderedMetadata<IApiRequestMetadataProvider>();
+            var declaredContentTypes = DefaultApiDescriptionProvider.GetDeclaredContentTypes(requestMetadata);
+
+            if (declaredContentTypes.Count > 0)
+            {
+                foreach (var contentType in declaredContentTypes)
+                {
+                    supportedRequestFormats.Add(new ApiRequestFormat
+                    {
+                        MediaType = contentType,
+                    });
+                }
+            }
+            else if (hasJsonBody)
+            {
+                supportedRequestFormats.Add(new ApiRequestFormat
+                {
+                    MediaType = "application/json",
+                });
+            }
+        }
+
+        private static void AddSupportedResponseTypes(
+            IList<ApiResponseType> supportedResponseTypes,
+            Type returnType,
+            EndpointMetadataCollection endpointMetadata)
+        {
+            var responseType = returnType;
+
+            if (AwaitableInfo.IsTypeAwaitable(responseType, out var awaitableInfo))
+            {
+                responseType = awaitableInfo.ResultType;
+            }
+
+            // Can't determine anything about IResults yet that's not from extra metadata. IResult<T> could help here.
+            if (typeof(IResult).IsAssignableFrom(responseType))
+            {
+                responseType = typeof(void);
+            }
+
+            var responseMetadata = endpointMetadata.GetOrderedMetadata<IApiResponseMetadataProvider>();
+            var errorMetadata = endpointMetadata.GetMetadata<ProducesErrorResponseTypeAttribute>();
+            var defaultErrorType = errorMetadata?.Type ?? typeof(void);
+            var contentTypes = new MediaTypeCollection();
+
+            var responseMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(
+                responseMetadata, responseType, defaultErrorType, contentTypes);
+
+            if (responseMetadataTypes.Count > 0)
+            {
+                foreach (var apiResponseType in responseMetadataTypes)
+                {
+                    // void means no response type was specified by the metadata, so use whatever we inferred.
+                    // ApiResponseTypeProvider should never return ApiResponseTypes with null Type, but it doesn't hurt to check.
+                    if (apiResponseType.Type is null || apiResponseType.Type == typeof(void))
+                    {
+                        apiResponseType.Type = responseType;
+                    }
+
+                    apiResponseType.ModelMetadata = CreateModelMetadata(apiResponseType.Type);
+
+                    if (contentTypes.Count > 0)
+                    {
+                        AddResponseContentTypes(apiResponseType.ApiResponseFormats, contentTypes);
+                    }
+                    else if (CreateDefaultApiResponseFormat(responseType) is { } defaultResponseFormat)
+                    {
+                        apiResponseType.ApiResponseFormats.Add(defaultResponseFormat);
+                    }
+
+                    supportedResponseTypes.Add(apiResponseType);
+                }
+            }
+            else
+            {
+                // Set the default response type only when none has already been set explicitly with metadata.
+                var defaultApiResponseType = CreateDefaultApiResponseType(responseType);
+
+                if (contentTypes.Count > 0)
+                {
+                    // If metadata provided us with response formats, use that instead of the default.
+                    defaultApiResponseType.ApiResponseFormats.Clear();
+                    AddResponseContentTypes(defaultApiResponseType.ApiResponseFormats, contentTypes);
+                }
+
+                supportedResponseTypes.Add(defaultApiResponseType);
+            }
+        }
+
+        private static ApiResponseType CreateDefaultApiResponseType(Type responseType)
+        {
+            var apiResponseType = new ApiResponseType
+            {
+                ModelMetadata = CreateModelMetadata(responseType),
+                StatusCode = 200,
+                Type = responseType,
+            };
+
+            if (CreateDefaultApiResponseFormat(responseType) is { } responseFormat)
+            {
+                apiResponseType.ApiResponseFormats.Add(responseFormat);
+            }
+
+            return apiResponseType;
+        }
+
+        private static ApiResponseFormat? CreateDefaultApiResponseFormat(Type responseType)
+        {
+            if (responseType == typeof(void))
+            {
+                return null;
+            }
+            else if (responseType == typeof(string))
+            {
+                // This uses HttpResponse.WriteAsync(string) method which doesn't set a content type. It could be anything,
+                // but I think "text/plain" is a reasonable assumption if nothing else is specified with metadata.
+                return new ApiResponseFormat { MediaType = "text/plain" };
+            }
+            else
+            {
+                // Everything else is written using HttpResponse.WriteAsJsonAsync<TValue>(T).
+                return new ApiResponseFormat { MediaType = "application/json" };
+            }
+        }
+
+        private static EndpointModelMetadata CreateModelMetadata(Type type) =>
+            new EndpointModelMetadata(ModelMetadataIdentity.ForType(type));
+
+        private static void AddResponseContentTypes(IList<ApiResponseFormat> apiResponseFormats, IReadOnlyList<string> contentTypes)
+        {
+            foreach (var contentType in contentTypes)
+            {
+                apiResponseFormats.Add(new ApiResponseFormat
+                {
+                    MediaType = contentType,
+                });
+            }
+        }
+
+        // The CompilerGeneratedAttribute doesn't always get added so we also check if the type name starts with "<"
+        // For example,w "<>c" is a "declaring" type the C# compiler will generate without the attribute for a top-level lambda
+        // REVIEW: Is there a better way to do this?
+        private static bool IsCompilerGenerated(Type type) =>
+            Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute)) || type.Name.StartsWith('<');
+    }
+}
diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs
new file mode 100644
index 00000000000..fc5fc426763
--- /dev/null
+++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs
@@ -0,0 +1,57 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+
+namespace Microsoft.AspNetCore.Mvc.ApiExplorer
+{
+    internal class EndpointModelMetadata : ModelMetadata
+    {
+        public EndpointModelMetadata(ModelMetadataIdentity identity) : base(identity)
+        {
+            IsBindingAllowed = true;
+        }
+
+        public override IReadOnlyDictionary<object, object> AdditionalValues { get; } = ImmutableDictionary<object, object>.Empty;
+        public override string? BinderModelName { get; }
+        public override Type? BinderType { get; }
+        public override BindingSource? BindingSource { get; }
+        public override bool ConvertEmptyStringToNull { get; }
+        public override string? DataTypeName { get; }
+        public override string? Description { get; }
+        public override string? DisplayFormatString { get; }
+        public override string? DisplayName { get; }
+        public override string? EditFormatString { get; }
+        public override ModelMetadata? ElementMetadata { get; }
+        public override IEnumerable<KeyValuePair<EnumGroupAndName, string>>? EnumGroupedDisplayNamesAndValues { get; }
+        public override IReadOnlyDictionary<string, string>? EnumNamesAndValues { get; }
+        public override bool HasNonDefaultEditFormat { get; }
+        public override bool HideSurroundingHtml { get; }
+        public override bool HtmlEncode { get; }
+        public override bool IsBindingAllowed { get; }
+        public override bool IsBindingRequired { get; }
+        public override bool IsEnum { get; }
+        public override bool IsFlagsEnum { get; }
+        public override bool IsReadOnly { get; }
+        public override bool IsRequired { get; }
+        public override ModelBindingMessageProvider ModelBindingMessageProvider { get; } = new DefaultModelBindingMessageProvider();
+        public override string? NullDisplayText { get; }
+        public override int Order { get; }
+        public override string? Placeholder { get; }
+        public override ModelPropertyCollection Properties { get; } = new(Enumerable.Empty<ModelMetadata>());
+        public override IPropertyFilterProvider? PropertyFilterProvider { get; }
+        public override Func<object, object>? PropertyGetter { get; }
+        public override Action<object, object?>? PropertySetter { get; }
+        public override bool ShowForDisplay { get; }
+        public override bool ShowForEdit { get; }
+        public override string? SimpleDisplayProperty { get; }
+        public override string? TemplateHint { get; }
+        public override bool ValidateChildren { get; }
+        public override IReadOnlyList<object> ValidatorMetadata { get; } = Array.Empty<object>();
+    }
+}
diff --git a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
index a3ee0007eed..4b86355d108 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
+++ b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj
@@ -9,6 +9,10 @@
     <IsPackable>false</IsPackable>
   </PropertyGroup>
 
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" />
+  </ItemGroup>
+
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Mvc.Core" />
   </ItemGroup>
diff --git a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt
index 069adc68ef6..e06fd7f7a79 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt
+++ b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt
@@ -22,6 +22,8 @@ Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollection.ApiDescriptio
 Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollection.Items.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroup!>!
 Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollectionProvider.ApiDescriptionGroupCollectionProvider(Microsoft.AspNetCore.Mvc.Infrastructure.IActionDescriptorCollectionProvider! actionDescriptorCollectionProvider, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider!>! apiDescriptionProviders) -> void
 Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollectionProvider.ApiDescriptionGroups.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollection!
+Microsoft.Extensions.DependencyInjection.EndpointMetadataApiExplorerServiceCollectionExtensions
+static Microsoft.Extensions.DependencyInjection.EndpointMetadataApiExplorerServiceCollectionExtensions.AddEndpointsApiExplorer(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
 ~Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider.DefaultApiDescriptionProvider(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.MvcOptions!>! optionsAccessor, Microsoft.AspNetCore.Routing.IInlineConstraintResolver! constraintResolver, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider! modelMetadataProvider, Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultTypeMapper! mapper, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Routing.RouteOptions!>! routeOptions) -> void
 Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider.OnProvidersExecuted(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext! context) -> void
 Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider.OnProvidersExecuting(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext! context) -> void
diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs
new file mode 100644
index 00000000000..cd39f51ca3b
--- /dev/null
+++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs
@@ -0,0 +1,378 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Mvc.ApiExplorer
+{
+    public class EndpointMetadataApiDescriptionProviderTest
+    {
+        [Fact]
+        public void MultipleApiDescriptionsCreatedForMultipleHttpMethods()
+        {
+            var apiDescriptions = GetApiDescriptions(() => { }, "/", new string[] { "FOO", "BAR" });
+
+            Assert.Equal(2, apiDescriptions.Count);
+        }
+
+        [Fact]
+        public void ApiDescriptionNotCreatedIfNoHttpMethods()
+        {
+            var apiDescriptions = GetApiDescriptions(() => { }, "/", Array.Empty<string>());
+
+            Assert.Empty(apiDescriptions);
+        }
+
+        [Fact]
+        public void UsesDeclaringTypeAsControllerName()
+        {
+            var apiDescription = GetApiDescription(TestAction);
+
+            var declaringTypeName = typeof(EndpointMetadataApiDescriptionProviderTest).Name;
+            Assert.Equal(declaringTypeName, apiDescription.ActionDescriptor.RouteValues["controller"]);
+        }
+
+        [Fact]
+        public void UsesApplicationNameAsControllerNameIfNoDeclaringType()
+        {
+            var apiDescription = GetApiDescription(() => { });
+
+            Assert.Equal(nameof(EndpointMetadataApiDescriptionProviderTest), apiDescription.ActionDescriptor.RouteValues["controller"]);
+        }
+
+        [Fact]
+        public void AddsJsonRequestFormatWhenFromBodyInferred()
+        {
+            static void AssertJsonRequestFormat(ApiDescription apiDescription)
+            {
+                var requestFormat = Assert.Single(apiDescription.SupportedRequestFormats);
+                Assert.Equal("application/json", requestFormat.MediaType);
+                Assert.Null(requestFormat.Formatter);
+            }
+
+            AssertJsonRequestFormat(GetApiDescription(
+                (InferredJsonClass fromBody) => { }));
+
+            AssertJsonRequestFormat(GetApiDescription(
+                ([FromBody] int fromBody) => { }));
+        }
+
+        [Fact]
+        public void AddsRequestFormatFromMetadata()
+        {
+            static void AssertustomRequestFormat(ApiDescription apiDescription)
+            {
+                var requestFormat = Assert.Single(apiDescription.SupportedRequestFormats);
+                Assert.Equal("application/custom", requestFormat.MediaType);
+                Assert.Null(requestFormat.Formatter);
+            }
+
+            AssertustomRequestFormat(GetApiDescription(
+                [Consumes("application/custom")]
+                (InferredJsonClass fromBody) => { }));
+
+            AssertustomRequestFormat(GetApiDescription(
+                [Consumes("application/custom")]
+                ([FromBody] int fromBody) => { }));
+        }
+
+        [Fact]
+        public void AddsMultipleRequestFormatsFromMetadata()
+        {
+            var apiDescription = GetApiDescription(
+                [Consumes("application/custom0", "application/custom1")]
+                (InferredJsonClass fromBody) => { });
+
+            Assert.Equal(2, apiDescription.SupportedRequestFormats.Count);
+
+            var requestFormat0 = apiDescription.SupportedRequestFormats[0];
+            Assert.Equal("application/custom0", requestFormat0.MediaType);
+            Assert.Null(requestFormat0.Formatter);
+
+            var requestFormat1 = apiDescription.SupportedRequestFormats[1];
+            Assert.Equal("application/custom1", requestFormat1.MediaType);
+            Assert.Null(requestFormat1.Formatter);
+        }
+
+        [Fact]
+        public void AddsJsonResponseFormatWhenFromBodyInferred()
+        {
+            static void AssertJsonResponse(ApiDescription apiDescription, Type expectedType)
+            {
+                var responseType = Assert.Single(apiDescription.SupportedResponseTypes);
+                Assert.Equal(200, responseType.StatusCode);
+                Assert.Equal(expectedType, responseType.Type);
+                Assert.Equal(expectedType, responseType.ModelMetadata.ModelType);
+
+                var responseFormat = Assert.Single(responseType.ApiResponseFormats);
+                Assert.Equal("application/json", responseFormat.MediaType);
+                Assert.Null(responseFormat.Formatter);
+            }
+
+            AssertJsonResponse(GetApiDescription(() => new InferredJsonClass()), typeof(InferredJsonClass));
+            AssertJsonResponse(GetApiDescription(() => (IInferredJsonInterface)null), typeof(IInferredJsonInterface));
+        }
+
+        [Fact]
+        public void AddsTextResponseFormatWhenFromBodyInferred()
+        {
+            var apiDescription = GetApiDescription(() => "foo");
+
+            var responseType = Assert.Single(apiDescription.SupportedResponseTypes);
+            Assert.Equal(200, responseType.StatusCode);
+            Assert.Equal(typeof(string), responseType.Type);
+            Assert.Equal(typeof(string), responseType.ModelMetadata.ModelType);
+
+            var responseFormat = Assert.Single(responseType.ApiResponseFormats);
+            Assert.Equal("text/plain", responseFormat.MediaType);
+            Assert.Null(responseFormat.Formatter);
+        }
+
+        [Fact]
+        public void AddsNoResponseFormatWhenItCannotBeInferredAndTheresNoMetadata()
+        {
+            static void AssertVoid(ApiDescription apiDescription)
+            {
+                var responseType = Assert.Single(apiDescription.SupportedResponseTypes);
+                Assert.Equal(200, responseType.StatusCode);
+                Assert.Equal(typeof(void), responseType.Type);
+                Assert.Equal(typeof(void), responseType.ModelMetadata.ModelType);
+
+                Assert.Empty(responseType.ApiResponseFormats);
+            }
+
+            AssertVoid(GetApiDescription(() => { }));
+            AssertVoid(GetApiDescription(() => Task.CompletedTask));
+            AssertVoid(GetApiDescription(() => new ValueTask()));
+        }
+
+        [Fact]
+        public void AddsResponseFormatFromMetadata()
+        {
+            var apiDescription = GetApiDescription(
+                [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)]
+                [Produces("application/custom")]
+                () => new InferredJsonClass());
+
+            var responseType = Assert.Single(apiDescription.SupportedResponseTypes);
+
+            Assert.Equal(201, responseType.StatusCode);
+            Assert.Equal(typeof(TimeSpan), responseType.Type);
+            Assert.Equal(typeof(TimeSpan), responseType.ModelMetadata.ModelType);
+
+            var responseFormat = Assert.Single(responseType.ApiResponseFormats);
+            Assert.Equal("application/custom", responseFormat.MediaType);
+        }
+
+        [Fact]
+        public void AddsMultipleResponseFormatsFromMetadata()
+        {
+            var apiDescription = GetApiDescription(
+                [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)]
+                [ProducesResponseType(StatusCodes.Status400BadRequest)]
+                () => new InferredJsonClass());
+
+            Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);
+
+            var createdResponseType = apiDescription.SupportedResponseTypes[0];
+
+            Assert.Equal(201, createdResponseType.StatusCode);
+            Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
+            Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata.ModelType);
+
+            var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
+            Assert.Equal("application/json", createdResponseFormat.MediaType);
+
+            var badRequestResponseType = apiDescription.SupportedResponseTypes[1];
+
+            Assert.Equal(400, badRequestResponseType.StatusCode);
+            Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.Type);
+            Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.ModelMetadata.ModelType);
+
+            var badRequestResponseFormat = Assert.Single(badRequestResponseType.ApiResponseFormats);
+            Assert.Equal("application/json", badRequestResponseFormat.MediaType);
+        }
+
+        [Fact]
+        public void AddsFromRouteParameterAsPath()
+        {
+            static void AssertPathParameter(ApiDescription apiDescription)
+            {
+                var param = Assert.Single(apiDescription.ParameterDescriptions);
+                Assert.Equal(typeof(int), param.Type);
+                Assert.Equal(typeof(int), param.ModelMetadata.ModelType);
+                Assert.Equal(BindingSource.Path, param.Source);
+            }
+
+            AssertPathParameter(GetApiDescription((int foo) => { }, "/{foo}"));
+            AssertPathParameter(GetApiDescription(([FromRoute] int foo) => { }));
+        }
+
+        [Fact]
+        public void AddsFromQueryParameterAsQuery()
+        {
+            static void AssertQueryParameter(ApiDescription apiDescription)
+            {
+                var param = Assert.Single(apiDescription.ParameterDescriptions);
+                Assert.Equal(typeof(int), param.Type);
+                Assert.Equal(typeof(int), param.ModelMetadata.ModelType);
+                Assert.Equal(BindingSource.Query, param.Source);
+            }
+
+            AssertQueryParameter(GetApiDescription((int foo) => { }, "/"));
+            AssertQueryParameter(GetApiDescription(([FromQuery] int foo) => { }));
+        }
+
+        [Fact]
+        public void AddsFromHeaderParameterAsHeader()
+        {
+            var apiDescription = GetApiDescription(([FromHeader] int foo) => { });
+            var param = Assert.Single(apiDescription.ParameterDescriptions);
+
+            Assert.Equal(typeof(int), param.Type);
+            Assert.Equal(typeof(int), param.ModelMetadata.ModelType);
+            Assert.Equal(BindingSource.Header, param.Source);
+        }
+
+        [Fact]
+        public void AddsFromServiceParameterAsService()
+        {
+            static void AssertServiceParameter(ApiDescription apiDescription, Type expectedType)
+            {
+                var param = Assert.Single(apiDescription.ParameterDescriptions);
+                Assert.Equal(expectedType, param.Type);
+                Assert.Equal(expectedType, param.ModelMetadata.ModelType);
+                Assert.Equal(BindingSource.Services, param.Source);
+            }
+
+            AssertServiceParameter(GetApiDescription((IInferredServiceInterface foo) => { }), typeof(IInferredServiceInterface));
+            AssertServiceParameter(GetApiDescription(([FromServices] int foo) => { }), typeof(int));
+            AssertServiceParameter(GetApiDescription((HttpContext context) => { }), typeof(HttpContext));
+            AssertServiceParameter(GetApiDescription((CancellationToken token) => { }), typeof(CancellationToken));
+        }
+
+        [Fact]
+        public void AddsFromBodyParameterAsBody()
+        {
+            static void AssertBodyParameter(ApiDescription apiDescription, Type expectedType)
+            {
+                var param = Assert.Single(apiDescription.ParameterDescriptions);
+                Assert.Equal(expectedType, param.Type);
+                Assert.Equal(expectedType, param.ModelMetadata.ModelType);
+                Assert.Equal(BindingSource.Body, param.Source);
+            }
+
+            AssertBodyParameter(GetApiDescription((InferredJsonClass foo) => { }), typeof(InferredJsonClass));
+            AssertBodyParameter(GetApiDescription(([FromBody] int foo) => { }), typeof(int));
+        }
+
+        [Fact]
+        public void AddsDefaultValueFromParameters()
+        {
+            var apiDescription = GetApiDescription(TestActionWithDefaultValue);
+
+            var param = Assert.Single(apiDescription.ParameterDescriptions);
+            Assert.Equal(42, param.DefaultValue);
+        }
+
+        [Fact]
+        public void AddsMultipleParameters()
+        {
+            var apiDescription = GetApiDescription(([FromRoute] int foo, int bar, InferredJsonClass fromBody) => { });
+            Assert.Equal(3, apiDescription.ParameterDescriptions.Count);
+
+            var fooParam = apiDescription.ParameterDescriptions[0];
+            Assert.Equal(typeof(int), fooParam.Type);
+            Assert.Equal(typeof(int), fooParam.ModelMetadata.ModelType);
+            Assert.Equal(BindingSource.Path, fooParam.Source);
+
+            var barParam = apiDescription.ParameterDescriptions[1];
+            Assert.Equal(typeof(int), barParam.Type);
+            Assert.Equal(typeof(int), barParam.ModelMetadata.ModelType);
+            Assert.Equal(BindingSource.Query, barParam.Source);
+
+            var fromBodyParam = apiDescription.ParameterDescriptions[2];
+            Assert.Equal(typeof(InferredJsonClass), fromBodyParam.Type);
+            Assert.Equal(typeof(InferredJsonClass), fromBodyParam.ModelMetadata.ModelType);
+            Assert.Equal(BindingSource.Body, fromBodyParam.Source);
+        }
+
+        private IList<ApiDescription> GetApiDescriptions(
+            Delegate action,
+            string pattern = null,
+            IEnumerable<string> httpMethods = null)
+        {
+            var methodInfo = action.Method;
+            var attributes = methodInfo.GetCustomAttributes();
+            var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>());
+
+            var httpMethodMetadata = new HttpMethodMetadata(httpMethods ?? new[] { "GET" });
+            var metadataItems = new List<object>(attributes) { methodInfo, httpMethodMetadata };
+            var endpointMetadata = new EndpointMetadataCollection(metadataItems.ToArray());
+            var routePattern = RoutePatternFactory.Parse(pattern ?? "/");
+
+            var endpoint = new RouteEndpoint(httpContext => Task.CompletedTask, routePattern, 0, endpointMetadata, null);
+            var endpointDataSource = new DefaultEndpointDataSource(endpoint);
+            var hostEnvironment = new HostEnvironment
+            {
+                ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest)
+            };
+
+            var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService());
+
+            provider.OnProvidersExecuting(context);
+            provider.OnProvidersExecuted(context);
+
+            return context.Results;
+        }
+
+        private ApiDescription GetApiDescription(Delegate action, string pattern = null) =>
+            Assert.Single(GetApiDescriptions(action, pattern));
+
+        private static void TestAction()
+        {
+        }
+
+        private static void TestActionWithDefaultValue(int foo = 42)
+        {
+        }
+
+        private class InferredJsonClass
+        {
+        }
+
+        private interface IInferredServiceInterface
+        {
+        }
+
+        private interface IInferredJsonInterface
+        {
+        }
+
+        private class ServiceProviderIsService : IServiceProviderIsService
+        {
+            public bool IsService(Type serviceType) => serviceType == typeof(IInferredServiceInterface);
+        }
+ 
+        private class HostEnvironment : IHostEnvironment
+        {
+            public string EnvironmentName { get; set; }
+            public string ApplicationName { get; set; }
+            public string ContentRootPath { get; set; }
+            public IFileProvider ContentRootFileProvider { get; set; }
+        }
+    }
+}
diff --git a/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj b/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj
index 3f1a6d73746..dce16986fb5 100644
--- a/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj
+++ b/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj
@@ -2,6 +2,7 @@
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <LangVersion>Preview</LangVersion>
   </PropertyGroup>
 
   <ItemGroup>
diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf
index b6e6c9fbf53..0d133ef4b69 100644
--- a/src/Mvc/Mvc.slnf
+++ b/src/Mvc/Mvc.slnf
@@ -32,6 +32,7 @@
       "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj",
       "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj",
       "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj",
+      "src\\Http\\samples\\MinimalSample\\MinimalSample.csproj",
       "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj",
       "src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj",
       "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj",
diff --git a/src/Shared/TryParseMethodCache.cs b/src/Shared/TryParseMethodCache.cs
new file mode 100644
index 00000000000..ba378a59d08
--- /dev/null
+++ b/src/Shared/TryParseMethodCache.cs
@@ -0,0 +1,85 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Reflection;
+
+namespace Microsoft.AspNetCore.Http
+{
+    internal static class TryParseMethodCache
+    {
+        private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod();
+
+        // Since this is shared source, the cache won't be shared between RequestDelegateFactory and the ApiDescriptionProvider sadly :(
+        private static readonly ConcurrentDictionary<Type, MethodInfo?> Cache = new();
+
+        public static bool HasTryParseMethod(ParameterInfo parameter)
+        {
+            var nonNullableParameterType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
+            return FindTryParseMethod(nonNullableParameterType) is not null;
+        }
+
+        // TODO: Use InvariantCulture where possible? Or is CurrentCulture fine because it's more flexible?
+        public static MethodInfo? FindTryParseMethod(Type type)
+        {
+            static MethodInfo? Finder(Type type)
+            {
+                if (type.IsEnum)
+                {
+                    return EnumTryParseMethod.MakeGenericMethod(type);
+                }
+
+                var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static);
+
+                foreach (var method in staticMethods)
+                {
+                    if (method.Name != "TryParse" || method.ReturnType != typeof(bool))
+                    {
+                        continue;
+                    }
+
+                    var tryParseParameters = method.GetParameters();
+
+                    if (tryParseParameters.Length == 2 &&
+                        tryParseParameters[0].ParameterType == typeof(string) &&
+                        tryParseParameters[1].IsOut &&
+                        tryParseParameters[1].ParameterType == type.MakeByRefType())
+                    {
+                        return method;
+                    }
+                }
+
+                return null;
+            }
+
+            return Cache.GetOrAdd(type, Finder);
+        }
+
+        private static MethodInfo GetEnumTryParseMethod()
+        {
+            var staticEnumMethods = typeof(Enum).GetMethods(BindingFlags.Public | BindingFlags.Static);
+
+            foreach (var method in staticEnumMethods)
+            {
+                if (!method.IsGenericMethod || method.Name != nameof(Enum.TryParse) || method.ReturnType != typeof(bool))
+                {
+                    continue;
+                }
+
+                var tryParseParameters = method.GetParameters();
+
+                if (tryParseParameters.Length == 2 &&
+                    tryParseParameters[0].ParameterType == typeof(string) &&
+                    tryParseParameters[1].IsOut)
+                {
+                    return method;
+                }
+            }
+
+            Debug.Fail("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) not found.");
+            throw new Exception("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) not found.");
+        }
+    }
+}
-- 
GitLab