File: EndpointMetadataApiDescriptionProvider.cs
Web Access
Project: src\src\Mvc\Mvc.ApiExplorer\src\Microsoft.AspNetCore.Mvc.ApiExplorer.csproj (Microsoft.AspNetCore.Mvc.ApiExplorer)
// 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 System.Runtime.CompilerServices;
using System.Security.Claims;
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;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.AspNetCore.Mvc.ApiExplorer;
 
internal sealed class EndpointMetadataApiDescriptionProvider : IApiDescriptionProvider
{
    private readonly EndpointDataSource _endpointDataSource;
    private readonly IHostEnvironment _environment;
    private readonly IServiceProviderIsService? _serviceProviderIsService;
    private readonly ParameterBindingMethodCache ParameterBindingMethodCache = new();
    private readonly ParameterPolicyFactory _parameterPolicyFactory;
 
    // Executes before MVC's DefaultApiDescriptionProvider and GrpcJsonTranscodingDescriptionProvider for no particular reason.
    public int Order => -1100;
 
    public EndpointMetadataApiDescriptionProvider(
        EndpointDataSource endpointDataSource,
        IHostEnvironment environment,
        ParameterPolicyFactory parameterPolicyFactory,
        IServiceProviderIsService? serviceProviderIsService)
    {
        _endpointDataSource = endpointDataSource;
        _environment = environment;
        _serviceProviderIsService = serviceProviderIsService;
        _parameterPolicyFactory = parameterPolicyFactory;
    }
 
    public void OnProvidersExecuting(ApiDescriptionProviderContext context)
    {
        // Keep in sync with EndpointRouteBuilderExtensions.cs
        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);
        }
 
        foreach (var endpoint in _endpointDataSource.Endpoints)
        {
            if (endpoint is RouteEndpoint routeEndpoint &&
                routeEndpoint.Metadata.GetMetadata<MethodInfo>() is { } methodInfo &&
                routeEndpoint.Metadata.GetMetadata<IHttpMethodMetadata>() is { } httpMethodMetadata &&
                routeEndpoint.Metadata.GetMetadata<IExcludeFromDescriptionMetadata>() is null or { ExcludeFromDescription: false })
            {
                // We need to detect if any of the methods allow inferred body
                var disableInferredBody = httpMethodMetadata.HttpMethods.Any(ShouldDisableInferredBody);
 
                // 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, disableInferredBody));
                }
            }
        }
    }
 
    public void OnProvidersExecuted(ApiDescriptionProviderContext context)
    {
    }
 
    private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string httpMethod, MethodInfo methodInfo, bool disableInferredBody)
    {
        // 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 && !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;
        }
 
        var apiDescription = new ApiDescription
        {
            HttpMethod = httpMethod,
            GroupName = routeEndpoint.Metadata.GetMetadata<IEndpointGroupNameMetadata>()?.EndpointGroupName,
            RelativePath = routeEndpoint.RoutePattern.RawText?.TrimStart('/'),
            ActionDescriptor = new ActionDescriptor
            {
                DisplayName = routeEndpoint.DisplayName,
                RouteValues =
                {
                    ["controller"] = controllerName,
                },
            },
        };
 
        var hasBodyOrFormFileParameter = false;
 
        foreach (var parameter in PropertyAsParameterInfo.Flatten(methodInfo.GetParameters(), ParameterBindingMethodCache))
        {
            var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern, disableInferredBody);
 
            if (parameterDescription is { })
            {
                apiDescription.ParameterDescriptions.Add(parameterDescription);
 
                hasBodyOrFormFileParameter |=
                    parameterDescription.Source == BindingSource.Body ||
                    parameterDescription.Source == BindingSource.FormFile;
            }
        }
 
        // Get IAcceptsMetadata.
        var acceptsMetadata = routeEndpoint.Metadata.GetMetadata<IAcceptsMetadata>();
        if (acceptsMetadata is not null)
        {
            // Add a default body parameter if there was no explicitly defined parameter associated with
            // either the body or a form and the user explicity defined some metadata describing the
            // content types the endpoint consumes (such as Accepts<TRequest>(...) or [Consumes(...)]).
            if (!hasBodyOrFormFileParameter)
            {
                var acceptsRequestType = acceptsMetadata.RequestType;
                var isOptional = acceptsMetadata.IsOptional;
                var parameterDescription = new ApiParameterDescription
                {
                    Name = acceptsRequestType is not null ? acceptsRequestType.Name : typeof(void).Name,
                    ModelMetadata = CreateModelMetadata(acceptsRequestType ?? typeof(void)),
                    Source = BindingSource.Body,
                    Type = acceptsRequestType ?? typeof(void),
                    IsRequired = !isOptional,
                };
                apiDescription.ParameterDescriptions.Add(parameterDescription);
            }
 
            var supportedRequestFormats = apiDescription.SupportedRequestFormats;
 
            foreach (var contentType in acceptsMetadata.ContentTypes)
            {
                supportedRequestFormats.Add(new ApiRequestFormat
                {
                    MediaType = contentType
                });
            }
        }
 
        AddSupportedResponseTypes(apiDescription.SupportedResponseTypes, methodInfo.ReturnType, routeEndpoint.Metadata);
        AddActionDescriptorEndpointMetadata(apiDescription.ActionDescriptor, routeEndpoint.Metadata);
 
        return apiDescription;
    }
 
    private ApiParameterDescription? CreateApiParameterDescription(ParameterInfo parameter, RoutePattern pattern, bool disableInferredBody)
    {
        var (source, name, allowEmpty, paramType) = GetBindingSourceAndName(parameter, pattern, disableInferredBody);
 
        // Services are ignored because they are not request parameters.
        if (source == BindingSource.Services)
        {
            return null;
        }
 
        // Determine the "requiredness" based on nullability, default value or if allowEmpty is set
        var nullabilityContext = new NullabilityInfoContext();
        var nullability = nullabilityContext.Create(parameter);
        var isOptional = parameter is PropertyAsParameterInfo argument
            ? argument.IsOptional || allowEmpty
            : parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull || allowEmpty;
        var parameterDescriptor = CreateParameterDescriptor(parameter, pattern);
        var routeInfo = CreateParameterRouteInfo(pattern, parameter, isOptional);
 
        return new ApiParameterDescription
        {
            Name = name,
            ModelMetadata = CreateModelMetadata(paramType),
            Source = source,
            DefaultValue = parameter.DefaultValue,
            Type = parameter.ParameterType,
            IsRequired = !isOptional,
            ParameterDescriptor = parameterDescriptor,
            RouteInfo = routeInfo
        };
    }
 
    private static ParameterDescriptor CreateParameterDescriptor(ParameterInfo parameter, RoutePattern pattern)
    {
        var parameterName = parameter.Name ?? string.Empty;
        var name = pattern.GetParameter(parameterName)?.Name ?? parameterName;
        return new EndpointParameterDescriptor
        {
            Name = name,
            ParameterInfo = parameter,
            ParameterType = parameter.ParameterType,
        };
    }
 
    private ApiParameterRouteInfo? CreateParameterRouteInfo(RoutePattern pattern, ParameterInfo parameter, bool isOptional)
    {
        if (parameter.Name is null)
        {
            throw new InvalidOperationException($"Encountered a parameter of type '{parameter.ParameterType}' without a name. Parameters must have a name.");
        }
 
        // Only produce a `RouteInfo` property for parameters that are defined in the route template
        if (pattern.GetParameter(parameter.Name) is not RoutePatternParameterPart parameterPart)
        {
            return null;
        }
 
        var constraints = new List<IRouteConstraint>();
 
        if (pattern.ParameterPolicies.TryGetValue(parameter.Name, out var parameterPolicyReferences))
        {
            foreach (var parameterPolicyReference in parameterPolicyReferences)
            {
                var policy = _parameterPolicyFactory.Create(parameterPart, parameterPolicyReference);
                if (policy is IRouteConstraint generatedConstraint)
                {
                    constraints.Add(generatedConstraint);
                }
            }
        }
 
        return new ApiParameterRouteInfo()
        {
            Constraints = constraints.AsReadOnly(),
            DefaultValue = parameter.DefaultValue,
            IsOptional = isOptional
        };
    }
 
    // TODO: Share more of this logic with RequestDelegateFactory.CreateArgument(...) using RequestDelegateFactoryUtilities
    // which is shared source.
    private (BindingSource, string, bool, Type) GetBindingSourceAndName(ParameterInfo parameter, RoutePattern pattern, bool disableInferredBody)
    {
        var attributes = parameter.GetCustomAttributes();
 
        if (attributes.OfType<IFromRouteMetadata>().FirstOrDefault() is { } routeAttribute)
        {
            var parameterName = parameter.Name ?? string.Empty;
            var name = pattern.GetParameter(parameterName)?.Name ?? parameterName;
            return (BindingSource.Path, routeAttribute.Name ?? name, false, parameter.ParameterType);
        }
        else if (attributes.OfType<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute)
        {
            return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
        else if (attributes.OfType<IFromHeaderMetadata>().FirstOrDefault() is { } headerAttribute)
        {
            return (BindingSource.Header, headerAttribute.Name ?? parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
        else if (attributes.OfType<IFromBodyMetadata>().FirstOrDefault() is { } fromBodyAttribute)
        {
            return (BindingSource.Body, parameter.Name ?? string.Empty, fromBodyAttribute.AllowEmpty, parameter.ParameterType);
        }
        else if (attributes.OfType<IFromFormMetadata>().FirstOrDefault() is { } fromFormAttribute)
        {
            return (BindingSource.FormFile, fromFormAttribute.Name ?? parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
        else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType) || typeof(FromKeyedServicesAttribute) == 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 (BindingSource.Services, parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
        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 = EndpointModelMetadata.GetDisplayType(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 { } routeParam)
            {
                return (BindingSource.Path, routeParam.Name, false, displayType);
            }
            else
            {
                return (BindingSource.Query, parameter.Name ?? string.Empty, false, displayType);
            }
        }
        else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection))
        {
            return (BindingSource.FormFile, parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
        else if (disableInferredBody && (
                 parameter.ParameterType == typeof(string[]) ||
                 parameter.ParameterType == typeof(StringValues) ||
                 (parameter.ParameterType.IsArray && ParameterBindingMethodCache.HasTryParseMethod(parameter.ParameterType.GetElementType()!)) ))
        {
            return (BindingSource.Query, parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
        else
        {
            return (BindingSource.Body, parameter.Name ?? string.Empty, false, parameter.ParameterType);
        }
    }
 
    private static void AddSupportedResponseTypes(
        IList<ApiResponseType> supportedResponseTypes,
        Type returnType,
        EndpointMetadataCollection endpointMetadata)
    {
        var responseType = returnType;
 
        if (CoercedAwaitableInfo.IsTypeAwaitable(responseType, out var coercedAwaitableInfo))
        {
            responseType = coercedAwaitableInfo.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);
        }
 
        // We support attributes (which implement the IApiResponseMetadataProvider) interface
        // and types added via the extension methods (which implement IProducesResponseTypeMetadata).
        var responseProviderMetadata = endpointMetadata.GetOrderedMetadata<IApiResponseMetadataProvider>();
        var producesResponseMetadata = endpointMetadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
        var errorMetadata = endpointMetadata.GetMetadata<ProducesErrorResponseTypeAttribute>();
        var defaultErrorType = errorMetadata?.Type ?? typeof(void);
        var contentTypes = new MediaTypeCollection();
 
        var responseProviderMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(
            responseProviderMetadata, responseType, defaultErrorType, contentTypes);
        var producesResponseMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(producesResponseMetadata, responseType);
 
        // We favor types added via the extension methods (which implements IProducesResponseTypeMetadata)
        // over those that are added via attributes.
        var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat(responseProviderMetadataTypes.Values);
 
        if (responseMetadataTypes.Any())
        {
            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);
                }
                // Only set the default response type if it hasn't already been set via a
                // ProducesResponseTypeAttribute.
                else if (apiResponseType.ApiResponseFormats.Count == 0 && CreateDefaultApiResponseFormat(apiResponseType.Type) is { } defaultResponseFormat)
                {
                    apiResponseType.ApiResponseFormats.Add(defaultResponseFormat);
                }
 
                if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode))
                {
                    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(ModelMetadataIdentity.ForType(type));
 
    private static void AddResponseContentTypes(IList<ApiResponseFormat> apiResponseFormats, IReadOnlyList<string> contentTypes)
    {
        foreach (var contentType in contentTypes)
        {
            apiResponseFormats.Add(new ApiResponseFormat
            {
                MediaType = contentType,
            });
        }
    }
 
    private static void AddActionDescriptorEndpointMetadata(
        ActionDescriptor actionDescriptor,
        EndpointMetadataCollection endpointMetadata)
    {
        if (endpointMetadata.Count > 0)
        {
            // ActionDescriptor.EndpointMetadata is an empty array by
            // default so need to add the metadata into a new list.
            actionDescriptor.EndpointMetadata = new List<object>(endpointMetadata);
        }
    }
}