File: ApiResponseTypeProvider.cs
Web Access
Project: src\aspnetcore\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.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace Microsoft.AspNetCore.Mvc.ApiExplorer;

internal sealed class ApiResponseTypeProvider
{
    // ApiResponseType has Type, StatusCode and ApiResponseFormats (which keeps MediaTypes aka Content-Type)
    // so we need to distinguish per StatusCode+Type here
    internal readonly record struct ResponseKey(int StatusCode, Type? DeclaredType);

    private readonly IModelMetadataProvider _modelMetadataProvider;
    private readonly IActionResultTypeMapper _mapper;
    private readonly MvcOptions _mvcOptions;

    public ApiResponseTypeProvider(
        IModelMetadataProvider modelMetadataProvider,
        IActionResultTypeMapper mapper,
        MvcOptions mvcOptions)
    {
        _modelMetadataProvider = modelMetadataProvider;
        _mapper = mapper;
        _mvcOptions = mvcOptions;
    }

    public ICollection<ApiResponseType> GetApiResponseTypes(ControllerActionDescriptor action)
    {
        // We only provide response info if we can figure out a type that is a user-data type.
        // Void /Task object/IActionResult will result in no data.
        var declaredReturnType = GetDeclaredReturnType(action);
        var runtimeReturnType = GetRuntimeReturnType(declaredReturnType);

        var responseMetadataAttributes = GetResponseMetadataAttributes(action);
        if (!HasSignificantMetadataProvider(responseMetadataAttributes) &&
            action.Properties.TryGetValue(typeof(ApiConventionResult), out var result))
        {
            // Action does not have any conventions. Use conventions on it if present.
            var apiConventionResult = (ApiConventionResult)result!;

            // scope here is the highest - those are "significant" metadata providers, so we use the highest scope
            var apiConventionedAttributes = apiConventionResult.ResponseMetadataProviders.Select(x => new ApiResponseMetadataProviderWithScope(x, scope: int.MaxValue));
            responseMetadataAttributes.AddRange(apiConventionedAttributes);
        }

        var defaultErrorType = typeof(void);
        if (action.Properties.TryGetValue(typeof(ProducesErrorResponseTypeAttribute), out result))
        {
            defaultErrorType = ((ProducesErrorResponseTypeAttribute)result!).Type;
        }

        // ProducesResponseTypeAttribute implements both IApiResponseMetadataProvider and
        // IProducesResponseTypeMetadata. Filter attributes are already processed with scope
        // support via ReadFilterAttributeResponseMetadata, so we exclude them here to
        // avoid processing the same attribute twice. This leaves only "pure" endpoint metadata
        // entries (e.g., from TypedResults or custom IProducesResponseTypeMetadata implementations).
        var producesResponseMetadata = action.EndpointMetadata
            .OfType<IProducesResponseTypeMetadata>()
            .Where(m => m is not IApiResponseMetadataProvider)
            .ToList();
        var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType);
        return apiResponseTypes;
    }

    private static List<ApiResponseMetadataProviderWithScope> GetResponseMetadataAttributes(ControllerActionDescriptor action)
    {
        if (action.FilterDescriptors == null)
        {
            return new List<ApiResponseMetadataProviderWithScope>();
        }

        // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory
        // while searching for a filter that implements IApiResponseMetadataProvider.
        //
        // The workaround for that is to implement the metadata interface on the IFilterFactory.
        return action.FilterDescriptors
            .Where(fd => fd.Filter is IApiResponseMetadataProvider)
            .Select(fd => new ApiResponseMetadataProviderWithScope((IApiResponseMetadataProvider)fd.Filter, fd.Scope))
            .ToList();
    }

    private ICollection<ApiResponseType> GetApiResponseTypes(
       IReadOnlyList<ApiResponseMetadataProviderWithScope> responseMetadataAttributes,
       IReadOnlyList<IProducesResponseTypeMetadata> producesResponseMetadata,
       Type? declaredReturnType,
       Type defaultErrorType)
    {
        var contentTypes = new MediaTypeCollection();
        var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType<IApiResponseTypeMetadataProvider>();

        // Read response types from endpoint metadata (IProducesResponseTypeMetadata),
        // e.g. from TypedResults or .Produces<T>() extension methods.
        var endpointResponseTypes = ReadEndpointResponseMetadata(
            producesResponseMetadata,
            declaredReturnType,
            responseTypeMetadataProviders,
            _modelMetadataProvider);

        // Read response types from filter attributes (IApiResponseMetadataProvider),
        // e.g. [ProducesResponseType], [Produces], and conventions.
        var filterAttributeResponseTypes = ReadFilterAttributeResponseMetadata(
            responseMetadataAttributes,
            declaredReturnType,
            defaultErrorType,
            contentTypes,
            out var _,
            responseTypeMetadataProviders);

        // Attribute metadata takes precedence: for any status code defined by attributes,
        // all endpoint entries for that status code are replaced by the attribute entries.
        var attributeStatusCodes = filterAttributeResponseTypes.Values.Select(r => r.StatusCode).ToHashSet();
        var responseTypes = endpointResponseTypes
            .Where(kvp => !attributeStatusCodes.Contains(kvp.Key.StatusCode))
            .Concat(filterAttributeResponseTypes)
            .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);

        // Set the default status only when no status has already been set explicitly
        if (responseTypes.Count == 0 && declaredReturnType != null)
        {
            var defaultKey = new ResponseKey(StatusCodes.Status200OK, declaredReturnType);
            responseTypes.Add(defaultKey, new ApiResponseType
            {
                StatusCode = StatusCodes.Status200OK,
                Type = declaredReturnType,
            });
        }

        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!);
        }

        foreach (var apiResponse in responseTypes.Values)
        {
            CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider);
        }

        return responseTypes.Values
            .OrderBy(responseType => responseType.StatusCode)
            .ThenBy(responseType => responseType.Type?.Name)
            .ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType)
            .ToList();
    }

    // Shared with EndpointMetadataApiDescriptionProvider for Minimal API.
    internal static Dictionary<ResponseKey, ApiResponseType> ReadAttributeResponseMetadata(
        IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
        Type? declaredReturnType,
        Type? defaultErrorType,
        MediaTypeCollection contentTypes,
        out bool errorSetByDefault,
        IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders = null,
        IModelMetadataProvider? modelMetadataProvider = null)
    {
        // Minimal APIs do not have scopes — all metadata lives at the same level.
        // This overload wraps all providers at scope=0 and delegates to the scoped method.
        var responseMetadataAttributesWithScope = responseMetadataAttributes
            .Select(provider => new ApiResponseMetadataProviderWithScope(provider, scope: 0))
            .ToList();

        return ReadFilterAttributeResponseMetadata(responseMetadataAttributesWithScope, declaredReturnType, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider);
    }

    /// <summary>
    /// Reads response metadata from filter attributes (IApiResponseMetadataProvider) with scope support.
    /// Used by the controller path where FilterDescriptor.Scope provides real scope values
    /// (e.g., 10 for action, 20 for controller), and by conventions which use int.MaxValue.
    /// Entries are processed in descending scope order so higher-scope entries take precedence per status code.
    /// </summary>
    internal static Dictionary<ResponseKey, ApiResponseType> ReadFilterAttributeResponseMetadata(
        IReadOnlyList<ApiResponseMetadataProviderWithScope> responseMetadataAttributes,
        Type? declaredReturnType,
        Type? defaultErrorType,
        MediaTypeCollection contentTypes,
        out bool errorSetByDefault,
        IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders = null,
        IModelMetadataProvider? modelMetadataProvider = null)
    {
        errorSetByDefault = false;
        var results = new Dictionary<ResponseKey, ApiResponseType>();
        var statusCodeScopes = new Dictionary<int, int>();
        var contentTypesAlreadySet = false;

        // Get the content type that the action explicitly set to support.
        // Walk through all 'filter' attributes in descending scope order. Descending order ensures
        // that higher-scope entries (e.g., action-level) are processed first, so lower-scope entries
        // for the same status code can be skipped.
        if (responseMetadataAttributes != null)
        {
            foreach (var metadataAttributeWithScope in responseMetadataAttributes.OrderByDescending(attr => attr.Scope))
            {
                var metadataAttribute = metadataAttributeWithScope.Provider;
                var attributeScope = metadataAttributeWithScope.Scope;

                // All IApiResponseMetadataProvider attributes, except for ProducesResponseTypeAttribute
                // (which gets its own content type collection) and ProducesDefaultResponseTypeAttribute
                // (whose SetContentTypes is a no-op), can set shared content types. Since we iterate
                // in descending scope order, only the first (highest-scope) such attribute should set
                // content types. Lower-scope attributes must not overwrite content types already set
                // by a higher-scope one (e.g., action-level Produces overrides controller-level Produces).
                if (metadataAttribute is not ProducesResponseTypeAttribute
                    and not ProducesDefaultResponseTypeAttribute
                    && !contentTypesAlreadySet)
                {
                    metadataAttribute.SetContentTypes(contentTypes);
                    contentTypesAlreadySet = true;
                }

                var statusCode = metadataAttribute.StatusCode;

                var apiResponseType = new ApiResponseType
                {
                    Type = metadataAttribute.Type,
                    StatusCode = statusCode,
                    IsDefaultResponse = metadataAttribute is IApiDefaultResponseMetadataProvider,
                    Description = metadataAttribute.Description
                };

                if (apiResponseType.Type == typeof(void))
                {
                    if (declaredReturnType != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created))
                    {
                        // ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified.
                        // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a
                        // [ProducesResponseType(201)] instead of [ProducesResponseType(typeof(Person), 201] when typeof(Person) can be inferred
                        // from the return type.
                        apiResponseType.Type = declaredReturnType;
                    }
                    else if (IsClientError(statusCode))
                    {
                        // Determine whether or not the type was provided by the user. If so, favor it over the default
                        // error type for 4xx client errors if no response type is specified..
                        errorSetByDefault = metadataAttribute is ProducesResponseTypeAttribute { IsResponseTypeSetByDefault: true };
                        apiResponseType.Type = errorSetByDefault ? defaultErrorType : apiResponseType.Type;
                    }
                    else if (apiResponseType.IsDefaultResponse)
                    {
                        apiResponseType.Type = defaultErrorType;
                    }
                }

                // We special case the handling of ProducesResponseTypeAttributes since
                // multiple ProducesResponseTypeAttributes are permitted on a single
                // action/controller/etc. In that scenario, instead of picking the most-specific
                // set of content types (like we do with the Produces attribute above) we process
                // the content types for each attribute independently.
                if (metadataAttribute is ProducesResponseTypeAttribute)
                {
                    var attributeContentTypes = new MediaTypeCollection();
                    metadataAttribute.SetContentTypes(attributeContentTypes);
                    CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider);
                }

                if (apiResponseType.Type != null)
                {
                    var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type);

                    if (statusCodeScopes.TryGetValue(statusCode, out var existingScope))
                    {
                        // attributeScope > existingScope: cannot happend due to desc order processing
                        Debug.Assert(attributeScope <= existingScope);
                        // attributeScope < existingScope: skip, higher scope already claimed this status code

                        if (attributeScope == existingScope)
                        {
                            // Same scope, same key: merge content types
                            if (results.TryGetValue(key, out var existingEntry))
                            {
                                MergeApiResponse(existingEntry, apiResponseType);
                            }
                            else
                            {
                                // Same scope, different type: add alongside
                                results[key] = apiResponseType;
                            }
                        }
                    }
                    else
                    {
                        statusCodeScopes[statusCode] = attributeScope;
                        results[key] = apiResponseType;
                    }
                }
            }
        }

        return results;
    }

    internal static Dictionary<ResponseKey, ApiResponseType> ReadEndpointResponseMetadata(
        IReadOnlyList<IProducesResponseTypeMetadata> responseMetadata,
        Type? inferredType,
        IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders = null,
        IModelMetadataProvider? modelMetadataProvider = null)
    {
        var results = new Dictionary<ResponseKey, ApiResponseType>();

        foreach (var metadata in responseMetadata)
        {
            // Skip IResult types that implement IEndpointMetadataProvider (built-in framework types like TypedResults)
            // since they handle their own metadata population. Custom IResult implementations that don't implement
            // IEndpointMetadataProvider should be included in response metadata for API documentation.
            if (typeof(IResult).IsAssignableFrom(metadata.Type) && typeof(IEndpointMetadataProvider).IsAssignableFrom(metadata.Type))
            {
                continue;
            }

            var statusCode = metadata.StatusCode;

            var apiResponseType = new ApiResponseType
            {
                Type = metadata.Type,
                StatusCode = statusCode,
            };

            if (apiResponseType.Type == null)
            {
                if (inferredType != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created))
                {
                    // Allow setting the response type from the return type of the method if it has
                    // not been set explicitly by the method.
                    apiResponseType.Type = inferredType;
                }
            }

            var attributeContentTypes = new MediaTypeCollection();
            if (metadata.ContentTypes != null)
            {
                foreach (var contentType in metadata.ContentTypes)
                {
                    attributeContentTypes.Add(contentType);
                }
            }

            CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider);

            if (apiResponseType.Type != null)
            {
                var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type);
                if (results.TryGetValue(key, out var existingEntry))
                {
                    // Same (statusCode, type): merge content types.
                    // Example: .Produces<Product>(200, "json").Produces<Product>(200, "xml")
                    //   → (200, Product) with [json, xml]
                    MergeApiResponse(existingEntry, apiResponseType);
                }
                else
                {
                    // Different type for the same status code: add alongside.
                    // Example: .Produces<Product>(200, "json").Produces<Customer>(200, "xml")
                    //   → (200, Product) [json] + (200, Customer) [xml]
                    results[key] = apiResponseType;
                }
            }
        }

        return results;
    }

    // Shared with EndpointMetadataApiDescriptionProvider
    internal static void CalculateResponseFormatForType(ApiResponseType apiResponse, MediaTypeCollection declaredContentTypes, IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders, IModelMetadataProvider? modelMetadataProvider)
    {
        // If response formats have already been calculate for this type,
        // then exit early. This avoids populating the ApiResponseFormat for
        // types that have already been handled, specifically ProducesResponseTypes.
        if (apiResponse.ApiResponseFormats.Count > 0)
        {
            return;
        }

        // Given the content-types that were declared for this action, determine the formatters that support the content-type for the given
        // response type.
        // 1. Responses that do not specify an type do not have any associated content-type. This usually is meant for status-code only responses such
        // as return NotFound();
        // 2. When a type is specified, use GetSupportedContentTypes to expand wildcards and get the range of content-types formatters support.
        // 3. When no formatter supports the specified content-type, use the user specified value as is. This is useful in actions where the user
        // dictates the content-type.
        // e.g. [Produces("application/pdf")] Action() => FileStream("somefile.pdf", "application/pdf");
        var responseType = apiResponse.Type;
        if (responseType == null || responseType == typeof(void))
        {
            return;
        }

        apiResponse.ModelMetadata = modelMetadataProvider?.GetMetadataForType(responseType);

        foreach (var contentType in declaredContentTypes)
        {
            var isSupportedContentType = false;

            if (responseTypeMetadataProviders != null)
            {
                foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders)
                {
                    var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes(
                        contentType,
                        responseType);

                    if (formatterSupportedContentTypes == null)
                    {
                        continue;
                    }

                    isSupportedContentType = true;

                    foreach (var formatterSupportedContentType in formatterSupportedContentTypes)
                    {
                        apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
                        {
                            Formatter = (IOutputFormatter)responseTypeMetadataProvider,
                            MediaType = formatterSupportedContentType,
                        });
                    }
                }
            }

            if (!isSupportedContentType && contentType != null)
            {
                // No output formatter was found that supports this content type. Add the user specified content type as-is to the result.
                apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
                {
                    MediaType = contentType,
                });
            }
        }
    }

    private Type? GetDeclaredReturnType(ControllerActionDescriptor action)
    {
        var declaredReturnType = action.MethodInfo.ReturnType;
        if (declaredReturnType == typeof(void) ||
            declaredReturnType == typeof(Task) ||
            declaredReturnType == typeof(ValueTask))
        {
            return typeof(void);
        }

        // Unwrap the type if it's a Task<T>. The Task (non-generic) case was already handled.
        var unwrappedType = declaredReturnType;
        if (declaredReturnType.IsGenericType &&
            (declaredReturnType.GetGenericTypeDefinition() == typeof(Task<>) || declaredReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
        {
            unwrappedType = declaredReturnType.GetGenericArguments()[0];
        }

        // If the method is declared to return IActionResult, IResult or a derived class, that information
        // isn't valuable to the formatter.
        if (typeof(IActionResult).IsAssignableFrom(unwrappedType) ||
            typeof(IResult).IsAssignableFrom(unwrappedType))
        {
            return null;
        }

        // If we get here, the type should be a user-defined data type or an envelope type
        // like ActionResult<T>. The mapper service will unwrap envelopes.
        unwrappedType = _mapper.GetResultDataType(unwrappedType);
        return unwrappedType;
    }

    private static Type? GetRuntimeReturnType(Type? declaredReturnType)
    {
        // If we get here, then a filter didn't give us an answer, so we need to figure out if we
        // want to use the declared return type.
        //
        // We've already excluded Task, void, and IActionResult at this point.
        //
        // If the action might return any object, then assume we don't know anything about it.
        if (declaredReturnType == typeof(object))
        {
            return null;
        }

        return declaredReturnType;
    }

    private static void MergeApiResponse(ApiResponseType existing, ApiResponseType newEntry)
    {
        foreach (var format in newEntry.ApiResponseFormats)
        {
            if (!existing.ApiResponseFormats.Any(f => f.MediaType == format.MediaType))
            {
                existing.ApiResponseFormats.Add(format);
            }
        }

        // It may be ugly, but it is better to have all descriptions saved in metadata
        // than silently dropping random descriptions.
        // New line is "\n\n" as per https://spec.openapis.org/oas/v3.1.0#rich-text-formatting
        if (newEntry.Description is not null)
        {
            existing.Description = string.IsNullOrEmpty(existing.Description)
                ? newEntry.Description
                : existing.Description + "\n\n" + newEntry.Description;
        }
    }

    private static bool IsClientError(int statusCode)
    {
        return statusCode >= 400 && statusCode < 500;
    }

    private static bool HasSignificantMetadataProvider(IReadOnlyList<ApiResponseMetadataProviderWithScope> providers)
    {
        for (var i = 0; i < providers.Count; i++)
        {
            var provider = providers[i];

            if (provider.Provider is ProducesAttribute producesAttribute && producesAttribute.Type is null)
            {
                // ProducesAttribute that does not specify type is considered not significant.
                continue;
            }

            // Any other IApiResponseMetadataProvider is considered significant
            return true;
        }

        return false;
    }
}