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 94a3e73732a3..b9961e96de26 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 @@
-
+
+
diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
index b14ef5fb3d40..91f5660e9051 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 @@ public static class RequestDelegateFactory
private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo StringResultWriteResponseAsyncMethod = GetMethodInfo>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default));
private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = GetMethodInfo>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default));
- private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod();
private static readonly MethodInfo LogParameterBindingFailureMethod = GetMethodInfo>((httpContext, parameterType, parameterName, sourceValue) =>
Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue));
@@ -56,8 +53,6 @@ public static class RequestDelegateFactory
private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null));
- private static readonly ConcurrentDictionary TryParseMethodCache = new();
-
///
/// Creates a implementation for .
///
@@ -197,7 +192,7 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
{
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 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
{
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 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall,
};
}
- 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(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 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
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 bac3c83201dd..8ecbae2c39a6 100644
--- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
+++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
@@ -479,7 +479,7 @@ public void CreateThrowsInvalidOperationExceptionGivenUnnamedArgument()
var unnamedParameter = Expression.Parameter(typeof(int));
var lambda = Expression.Lambda(Expression.Block(), unnamedParameter);
var ex = Assert.Throws(() => RequestDelegateFactory.Create((Action)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 fb538f4d5283..9d8c6a7c33d7 100644
--- a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs
+++ b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs
@@ -61,7 +61,7 @@ public static MinimalActionEndpointConventionBuilder MapPost(
/// The to add the route to.
/// The route pattern.
/// The delegate executed when the endpoint is matched.
- /// A that canaction be used to further customize the endpoint.
+ /// A that can be used to further customize the endpoint.
public static MinimalActionEndpointConventionBuilder MapPut(
this IEndpointRouteBuilder endpoints,
string pattern,
@@ -166,6 +166,12 @@ public static MinimalActionEndpointConventionBuilder Map(
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 fec8da0ce7cc..f43d9d97d55d 100644
--- a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs
+++ b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs
@@ -42,7 +42,9 @@ void TestAction()
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().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 @@ void TestAction()
Assert.Equal("BUILDER", GetMethod(metadataArray[2]));
Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata()!.HttpMethods.Single());
-
- string GetMethod(object metadata)
- {
- var httpMethodMetadata = Assert.IsAssignableFrom(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 330ff630384d..fcf7dfcf05ab 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
+++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
@@ -77,13 +77,48 @@ private ICollection GetApiResponseTypes(
IReadOnlyList 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 ReadResponseMetadata(
+ IReadOnlyList responseMetadataAttributes,
+ Type? type,
+ Type defaultErrorType,
+ MediaTypeCollection contentTypes)
{
var results = new Dictionary();
// 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 @@ private ICollection GetApiResponseTypes(
{
// 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 @@ private ICollection GetApiResponseTypes(
}
}
- // 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 responseTypes, MediaTypeCollection declaredContentTypes)
diff --git a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
index 843216b21a9b..ea388cdfbb36 100644
--- a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
+++ b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
@@ -429,7 +429,7 @@ private IReadOnlyList GetSupportedFormats(MediaTypeCollection
return results;
}
- private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[]? requestMetadataAttributes)
+ internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList? 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 000000000000..0be4f4c89410
--- /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
+{
+ ///
+ /// Extensions for configuring ApiExplorer using .
+ ///
+ public static class EndpointMetadataApiExplorerServiceCollectionExtensions
+ {
+ ///
+ /// Configures ApiExplorer using .
+ ///
+ /// The .
+ public static IServiceCollection AddEndpointsApiExplorer(this IServiceCollection services)
+ {
+ // Try to add default services in case MVC services aren't added.
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ services.TryAddEnumerable(
+ ServiceDescriptor.Transient());
+
+ 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 000000000000..e4bef2e33fa7
--- /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() is { } methodInfo &&
+ routeEndpoint.Metadata.GetMetadata() 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().FirstOrDefault() is { } routeAttribute)
+ {
+ return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty);
+ }
+ else if (attributes.OfType().FirstOrDefault() is { } queryAttribute)
+ {
+ return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty);
+ }
+ else if (attributes.OfType().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 supportedRequestFormats,
+ bool hasJsonBody,
+ EndpointMetadataCollection endpointMetadata)
+ {
+ var requestMetadata = endpointMetadata.GetOrderedMetadata();
+ 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 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 could help here.
+ if (typeof(IResult).IsAssignableFrom(responseType))
+ {
+ responseType = typeof(void);
+ }
+
+ var responseMetadata = endpointMetadata.GetOrderedMetadata();
+ var errorMetadata = endpointMetadata.GetMetadata();
+ 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(T).
+ return new ApiResponseFormat { MediaType = "application/json" };
+ }
+ }
+
+ private static EndpointModelMetadata CreateModelMetadata(Type type) =>
+ new EndpointModelMetadata(ModelMetadataIdentity.ForType(type));
+
+ private static void AddResponseContentTypes(IList apiResponseFormats, IReadOnlyList 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 000000000000..fc5fc4267632
--- /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