diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs new file mode 100644 index 000000000000..d783d1b5916e --- /dev/null +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer; + +internal readonly struct ApiResponseMetadataProviderWithScope(IApiResponseMetadataProvider provider, int scope) +{ + public IApiResponseMetadataProvider Provider { get; } = provider; + public int Scope { get; } = scope; +} diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 7bdaaad11dca..e74c0ee93616 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -1,6 +1,7 @@ // 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; @@ -13,6 +14,10 @@ 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; @@ -32,7 +37,6 @@ public ICollection GetApiResponseTypes(ControllerActionDescript // 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); @@ -41,7 +45,10 @@ public ICollection GetApiResponseTypes(ControllerActionDescript { // Action does not have any conventions. Use conventions on it if present. var apiConventionResult = (ApiConventionResult)result!; - responseMetadataAttributes.AddRange(apiConventionResult.ResponseMetadataProviders); + + // 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); @@ -50,16 +57,24 @@ public ICollection GetApiResponseTypes(ControllerActionDescript defaultErrorType = ((ProducesErrorResponseTypeAttribute)result!).Type; } - var producesResponseMetadata = action.EndpointMetadata.OfType().ToList(); + // 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() + .Where(m => m is not IApiResponseMetadataProvider) + .ToList(); var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType); return apiResponseTypes; } - private static List GetResponseMetadataAttributes(ControllerActionDescriptor action) + private static List GetResponseMetadataAttributes(ControllerActionDescriptor action) { if (action.FilterDescriptors == null) { - return new List(); + return new List(); } // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory @@ -67,49 +82,54 @@ private static List GetResponseMetadataAttributes( // // The workaround for that is to implement the metadata interface on the IFilterFactory. return action.FilterDescriptors - .Select(fd => fd.Filter) - .OfType() + .Where(fd => fd.Filter is IApiResponseMetadataProvider) + .Select(fd => new ApiResponseMetadataProviderWithScope((IApiResponseMetadataProvider)fd.Filter, fd.Scope)) .ToList(); } private ICollection GetApiResponseTypes( - IReadOnlyList responseMetadataAttributes, + IReadOnlyList responseMetadataAttributes, IReadOnlyList producesResponseMetadata, - Type? type, + Type? declaredReturnType, Type defaultErrorType) { var contentTypes = new MediaTypeCollection(); var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); - var responseTypes = ReadResponseMetadata( + // Read response types from endpoint metadata (IProducesResponseTypeMetadata), + // e.g. from TypedResults or .Produces() extension methods. + var endpointResponseTypes = ReadEndpointResponseMetadata( producesResponseMetadata, - type, + declaredReturnType, responseTypeMetadataProviders, _modelMetadataProvider); - // Read response metadata from providers and - // overwrite responseTypes from the metadata based - // on the status code - var responseTypesFromProvider = ReadResponseMetadata( + // Read response types from filter attributes (IApiResponseMetadataProvider), + // e.g. [ProducesResponseType], [Produces], and conventions. + var filterAttributeResponseTypes = ReadFilterAttributeResponseMetadata( responseMetadataAttributes, - type, + declaredReturnType, defaultErrorType, contentTypes, out var _, responseTypeMetadataProviders); - foreach (var responseType in responseTypesFromProvider) - { - responseTypes[responseType.Key] = responseType.Value; - } + // 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 && type != null) + if (responseTypes.Count == 0 && declaredReturnType != null) { - responseTypes.Add(StatusCodes.Status200OK, new ApiResponseType + var defaultKey = new ResponseKey(StatusCodes.Status200OK, declaredReturnType); + responseTypes.Add(defaultKey, new ApiResponseType { StatusCode = StatusCodes.Status200OK, - Type = type, + Type = declaredReturnType, }); } @@ -128,13 +148,41 @@ private ICollection GetApiResponseTypes( CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider); } - return responseTypes.Values; + return responseTypes.Values + .OrderBy(responseType => responseType.StatusCode) + .ThenBy(responseType => responseType.Type?.Name) + .ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType) + .ToList(); } - // Shared with EndpointMetadataApiDescriptionProvider - internal static Dictionary ReadResponseMetadata( + // Shared with EndpointMetadataApiDescriptionProvider for Minimal API. + internal static Dictionary ReadAttributeResponseMetadata( IReadOnlyList responseMetadataAttributes, - Type? type, + Type? declaredReturnType, + Type? defaultErrorType, + MediaTypeCollection contentTypes, + out bool errorSetByDefault, + IEnumerable? 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); + } + + /// + /// 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. + /// + internal static Dictionary ReadFilterAttributeResponseMetadata( + IReadOnlyList responseMetadataAttributes, + Type? declaredReturnType, Type? defaultErrorType, MediaTypeCollection contentTypes, out bool errorSetByDefault, @@ -142,49 +190,54 @@ internal static Dictionary ReadResponseMetadata( IModelMetadataProvider? modelMetadataProvider = null) { errorSetByDefault = false; - var results = new Dictionary(); + var results = new Dictionary(); + var statusCodeScopes = new Dictionary(); + var contentTypesAlreadySet = false; // 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. + // 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 metadataAttribute in responseMetadataAttributes) + foreach (var metadataAttributeWithScope in responseMetadataAttributes.OrderByDescending(attr => attr.Scope)) { - // All ProducesXAttributes, except for ProducesResponseTypeAttribute do - // not allow multiple instances on the same method/class/etc. For those - // scenarios, the `SetContentTypes` method on the attribute continuously - // clears out more general content types in favor of more specific ones - // since we iterate through the attributes in order. For example, if a - // Produces exists on both a controller and an action within the controller, - // we favor the definition in the action. This is a semantic that does not - // apply to ProducesResponseType, which allows multiple instances on an target. - if (metadataAttribute is not ProducesResponseTypeAttribute) + 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 description = metadataAttribute.Description; - var apiResponseType = new ApiResponseType { Type = metadataAttribute.Type, StatusCode = statusCode, IsDefaultResponse = metadataAttribute is IApiDefaultResponseMetadataProvider, - Description = description + Description = metadataAttribute.Description }; if (apiResponseType.Type == typeof(void)) { - if (type != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + 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 = type; + apiResponseType.Type = declaredReturnType; } else if (IsClientError(statusCode)) { @@ -199,7 +252,7 @@ internal static Dictionary ReadResponseMetadata( } } - // We special case the handling of ProcuesResponseTypeAttributes since + // 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 @@ -213,7 +266,33 @@ internal static Dictionary ReadResponseMetadata( if (apiResponseType.Type != null) { - results[apiResponseType.StatusCode] = apiResponseType; + 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; + } } } } @@ -221,13 +300,13 @@ internal static Dictionary ReadResponseMetadata( return results; } - internal static Dictionary ReadResponseMetadata( + internal static Dictionary ReadEndpointResponseMetadata( IReadOnlyList responseMetadata, - Type? type, + Type? inferredType, IEnumerable? responseTypeMetadataProviders = null, IModelMetadataProvider? modelMetadataProvider = null) { - var results = new Dictionary(); + var results = new Dictionary(); foreach (var metadata in responseMetadata) { @@ -249,11 +328,11 @@ internal static Dictionary ReadResponseMetadata( if (apiResponseType.Type == null) { - if (type != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + 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 = type; + apiResponseType.Type = inferredType; } } @@ -270,7 +349,21 @@ internal static Dictionary ReadResponseMetadata( if (apiResponseType.Type != null) { - results[apiResponseType.StatusCode] = apiResponseType; + var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type); + if (results.TryGetValue(key, out var existingEntry)) + { + // Same (statusCode, type): merge content types. + // Example: .Produces(200, "json").Produces(200, "xml") + // → (200, Product) with [json, xml] + MergeApiResponse(existingEntry, apiResponseType); + } + else + { + // Different type for the same status code: add alongside. + // Example: .Produces(200, "json").Produces(200, "xml") + // → (200, Product) [json] + (200, Customer) [xml] + results[key] = apiResponseType; + } } } @@ -393,18 +486,39 @@ internal static void CalculateResponseFormatForType(ApiResponseType apiResponse, 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 providers) + private static bool HasSignificantMetadataProvider(IReadOnlyList providers) { for (var i = 0; i < providers.Count; i++) { var provider = providers[i]; - if (provider is ProducesAttribute producesAttribute && producesAttribute.Type is null) + if (provider.Provider is ProducesAttribute producesAttribute && producesAttribute.Type is null) { // ProducesAttribute that does not specify type is considered not significant. continue; diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 0b40e8813269..d8146b5aca51 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -340,13 +340,27 @@ private static void AddSupportedResponseTypes( responseType = typeof(void); } - var responseProviderMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata( + var responseProviderMetadataTypes = ApiResponseTypeProvider.ReadAttributeResponseMetadata( responseProviderMetadata, responseType, defaultErrorType, contentTypes, out var errorSetByDefault); - var producesResponseMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(producesResponseMetadata, responseType); + var producesResponseMetadataTypes = ApiResponseTypeProvider.ReadEndpointResponseMetadata(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); + // over those that are added via attributes (IApiResponseMetadataProvider). + // + // Note: TypedResults (e.g. TypedResults.Ok()) also add IProducesResponseTypeMetadata + // via IEndpointMetadataProvider, so they end up in the same bucket as .Produces() and + // coexist for the same status code. + // + // Example: + // [ProducesResponseType(typeof(string), 200)] // attribute → IApiResponseMetadataProvider + // app.MapPost("/", () => TypedResults.Ok(new Product())) // TypedResults → IProducesResponseTypeMetadata (200, Product) + // .Produces(200); // extension → IProducesResponseTypeMetadata (200, Customer) + // + // Result: (200, Product) and (200, Customer) both appear. The attribute (200, string) is + // dropped because status 200 is already claimed by IProducesResponseTypeMetadata entries. + var producesStatusCodes = producesResponseMetadataTypes.Values.Select(metadata => metadata.StatusCode).ToHashSet(); + var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat( + responseProviderMetadataTypes.Values.Where(metadata => !producesStatusCodes.Contains(metadata.StatusCode))); if (responseMetadataTypes.Any()) { @@ -377,7 +391,10 @@ private static void AddSupportedResponseTypes( apiResponseType.Description ??= GetMatchingResponseTypeDescription(responseProviderMetadataTypes.Values, apiResponseType); - if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode)) + if (!supportedResponseTypes.Any(existingResponseType => + existingResponseType.StatusCode == apiResponseType.StatusCode && + existingResponseType.Type == apiResponseType.Type && + existingResponseType.ApiResponseFormats.FirstOrDefault()?.MediaType == apiResponseType.ApiResponseFormats.FirstOrDefault()?.MediaType)) { supportedResponseTypes.Add(apiResponseType); } @@ -398,6 +415,23 @@ private static void AddSupportedResponseTypes( supportedResponseTypes.Add(defaultApiResponseType); } + if (supportedResponseTypes.Count > 1) + { + // With multiple response types (e.g., different types for the same status code), + // we need deterministic ordering so that API documentation is stable across runs. + // This matches the ordering used by the controller path in ApiResponseTypeProvider. + var sorted = supportedResponseTypes + .OrderBy(rt => rt.StatusCode) + .ThenBy(rt => rt.Type?.Name) + .ThenBy(rt => rt.ApiResponseFormats.FirstOrDefault()?.MediaType) + .ToArray(); + supportedResponseTypes.Clear(); + foreach (var sortedResponseType in sorted) + { + supportedResponseTypes.Add(sortedResponseType); + } + } + static string? GetMatchingResponseTypeDescription(IEnumerable responseMetadataTypes, ApiResponseType apiResponseType) { // We set the Description to the LAST non-null value we find that matches the status code. diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index 44fece97ea3e..466d2c3674fe 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -33,7 +33,7 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresent() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -85,6 +85,17 @@ public void GetApiResponseTypes_CombinesFilters() new FilterDescriptor(new ProducesResponseTypeAttribute(404), FilterScope.Action), }; + // Global: + // 400 => void (ignored, overriden by 400 status code in controller scope) + // -- + // Controller: + // 201 => object (ignored, overriden by 201 status code in action scope) + // 400 => ProblemDetails + // -- + // Action: + // 201 => BaseModel + // 404 => void + var actionDescriptor = new ControllerActionDescriptor { FilterDescriptors = filterDescriptors, @@ -98,7 +109,8 @@ public void GetApiResponseTypes_CombinesFilters() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, + // BaseModel; 201 => scope=Action responseType => { Assert.Equal(201, responseType.StatusCode); @@ -112,6 +124,7 @@ public void GetApiResponseTypes_CombinesFilters() Assert.IsType(format.Formatter); }); }, + // ProblemDetails; 400 => scope=Controller responseType => { Assert.Equal(400, responseType.StatusCode); @@ -125,6 +138,7 @@ public void GetApiResponseTypes_CombinesFilters() Assert.IsType(format.Formatter); }); }, + // 404; void => scope=Action responseType => { Assert.Equal(404, responseType.StatusCode); @@ -156,7 +170,7 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -213,7 +227,7 @@ public void GetApiResponseTypes_ReturnsDescriptionFromProducesResponseType() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -267,7 +281,7 @@ public void GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatch() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -306,7 +320,7 @@ public void GetApiResponseTypes_ReturnsDefaultProblemResponse() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.True(responseType.IsDefaultResponse); @@ -362,7 +376,7 @@ public void GetApiResponseTypes_ReturnsValuesFromProducesResponseType_IfApiConve // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(201, responseType.StatusCode); @@ -405,7 +419,7 @@ public void GetApiResponseTypes_UsesErrorType_ForClientErrors() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -457,7 +471,7 @@ public void GetApiResponseTypes_UsesErrorType_ForDefaultResponse() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(errorType, responseType.Type); @@ -500,7 +514,7 @@ public void GetApiResponseTypes_DoesNotUseErrorType_IfSpecified() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(typeof(DivideByZeroException), responseType.Type); @@ -551,7 +565,7 @@ public void GetApiResponseTypes_DoesNotUseErrorType_ForNonClientErrors() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(201, responseType.StatusCode); @@ -597,7 +611,7 @@ public void GetApiResponseTypes_AllowsUsingVoid() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.True(responseType.IsDefaultResponse); @@ -643,7 +657,7 @@ public void GetApiResponseTypes_CombinesProducesAttributeAndConventions() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.True(responseType.IsDefaultResponse); @@ -693,7 +707,7 @@ public void GetApiResponseTypes_DoesNotCombineProducesAttributeThatSpecifiesType // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -723,7 +737,7 @@ public void GetApiResponseTypes_DoesNotCombineProducesResponseTypeAttributeThatS // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -749,7 +763,7 @@ public void GetApiResponseTypes_UsesContentTypeWithoutWildCard_WhenNoFormatterSu // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -782,7 +796,7 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(typeof(BaseModel), responseType.Type); @@ -810,6 +824,28 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces }); } + [Fact] + public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeWithSameStatusCodeButDifferentTypesWithoutContentTypes() + { + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetMultipleTypes)); + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + + var result = provider.GetApiResponseTypes(actionDescriptor); + + Assert.Collection( + result, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + }, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(string), responseType.Type); + }); + } + [Fact] public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithBuiltIResultReturnType() { @@ -889,6 +925,10 @@ public class TestController public IResult GetIResult(int id) => null; + [ProducesResponseType(typeof(BaseModel), 200)] + [ProducesResponseType(typeof(string), 200)] + public IResult GetMultipleTypes() => Results.Ok(); + public MyResponse GetCustomIResult() => new MyResponse { Content = "Test Content" }; } @@ -912,6 +952,289 @@ public TestOutputFormatter() public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context) => Task.CompletedTask; } + [Fact] + public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeWithSameStatusCodeButDifferentContentTypes() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(MultipleProducesForSameStatusCodeController), + nameof(MultipleProducesForSameStatusCodeController.Get)); + + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(new[] { "text/html" }, GetSortedMediaTypes(responseType)); + }); + } + + public class MultipleProducesForSameStatusCodeController : ControllerBase + { + [ProducesResponseType(typeof(BaseModel), 200, "application/json")] + [ProducesResponseType(typeof(string), 200, "text/html")] + public IActionResult Get() => null; + } + + [Fact] + public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeFromEndpointMetadata() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(MultipleProducesForSameStatusCodeController), + nameof(MultipleProducesForSameStatusCodeController.Get)); + actionDescriptor.EndpointMetadata = + [ + new ProducesResponseTypeMetadata(200, typeof(BaseModel), ["application/json"]), + new ProducesResponseTypeMetadata(200, typeof(string), ["text/html"]), + ]; + + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(new[] { "text/html" }, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void GetApiResponseTypes_MergesContentTypesForSameStatusCodeAndTypeAtSameScope() + { + // Arrange — two [ProducesResponseType] for the same (200, BaseModel) at action scope with different content types. + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetUser)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 200, "application/json"), FilterScope.Action)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 200, "text/xml"), FilterScope.Action)); + + // Act + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert — single (200, BaseModel) with merged [json, xml] + Assert.Collection( + result, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void GetApiResponseTypes_ProducesAttribute_HighestScopeWins() + { + // Arrange — [Produces("text/xml")] at controller scope and [Produces("application/json")] at action scope. + // Action scope is higher, so "application/json" should be the shared content type. + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetUser)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("text/xml"), FilterScope.Controller)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("application/json"), FilterScope.Action)); + + // Act + var provider = GetProvider(); + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert — action-level "application/json" wins, controller-level "text/xml" is ignored + Assert.Collection( + result, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(DerivedModel), responseType.Type); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void GetApiResponseTypes_AttributesTakePrecedenceOverEndpointMetadata_ForOverlappingStatusCodes() + { + // Arrange — endpoint metadata provides (200, string, "text/html") and (404, ProblemDetails, "application/json"). + // Filter attribute claims status code 200 with (200, BaseModel, "application/json"). + // Attribute wins for 200, endpoint metadata 404 passes through. + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetUser)); + actionDescriptor.EndpointMetadata = + [ + new ProducesResponseTypeMetadata(200, typeof(string), ["text/html"]), + new ProducesResponseTypeMetadata(404, typeof(ProblemDetails), ["application/json"]), + ]; + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 200, "application/json"), FilterScope.Action)); + + // Act + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result, + responseType => + { + // Attribute wins for 200 + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + // Endpoint metadata 404 passes through (not claimed by attributes) + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void GetApiResponseTypes_DeterministicOrdering_ComplexScenario() + { + // [Produces("application/json")] // controller, scope=20 + // [ProducesResponseType(typeof(Error), 404)] // controller, scope=20 + // public class MyController + // { + // [ProducesResponseType(typeof(Foo), 200, "application/json")] // action, scope=10 + // [ProducesResponseType(typeof(Bar), 200, "text/xml")] // action, scope=10 + // [ProducesResponseType(typeof(Foo), 200, "text/plain")] // action, scope=10 + // [ProducesResponseType(404)] // action, scope=10 + // public IActionResult Get() { ... } + // } + // + // Expected output: + // 200 BaseModel [application/json, text/plain] → Foo merged + // 200 Bar [text/xml] + // 404 void [] → action scope wins (void), controller 404 Error ignored + + // Arrange + var filterDescriptors = new[] + { + // controller scope + new FilterDescriptor(new ProducesAttribute("application/json"), FilterScope.Controller), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(DerivedModel), 404), FilterScope.Controller), + // action scope + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 200, "application/json"), FilterScope.Action), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(DerivedModel), 200, "text/xml"), FilterScope.Action), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 200, "text/plain"), FilterScope.Action), + new FilterDescriptor(new ProducesResponseTypeAttribute(404), FilterScope.Action), + }; + + var actionDescriptor = new ControllerActionDescriptor + { + FilterDescriptors = filterDescriptors, + MethodInfo = typeof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController) + .GetMethod(nameof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController.Get)), + }; + + // Act + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert — ordered by StatusCode → Type.Name → first ContentType + Assert.Collection( + result, + responseType => + { + // 200 BaseModel [application/json, text/plain] — merged from two action-scope entries + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Equal(["application/json", "text/plain"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + // 200 DerivedModel [text/xml] — different type, added alongside + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(DerivedModel), responseType.Type); + Assert.Equal(["text/xml"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + // 404 void — action scope wins over controller's (404, DerivedModel). + // [Produces("application/json")] shared content type applied since no own formats. + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [Fact] + public void GetApiResponseTypes_DefaultFallback_VoidReturnType_Produces200WithNoFormats() + { + // Arrange — action returns void (Task), no attributes, no conventions. + var actionDescriptor = new ControllerActionDescriptor + { + MethodInfo = typeof(VoidController).GetMethod(nameof(VoidController.Delete)), + FilterDescriptors = [], + }; + + // Act + var provider = GetProvider(); + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert — default fallback produces (200, void) with empty formats + var responseType = Assert.Single(result); + Assert.Equal(200, responseType.StatusCode); + Assert.Null(responseType.ModelMetadata); + Assert.Empty(responseType.ApiResponseFormats); + } + + [Fact] + public void GetApiResponseTypes_HigherScopeProviderWithNullType_DoesNotBlockLowerScope() + { + // Arrange — a custom IApiResponseMetadataProvider at action scope returns Type=null for + // status 404. Because the entry is dropped (Type stays null and the `if (Type != null)` + // guard skips it), the action scope does NOT register a claim on status 404 in + // statusCodeScopes. A controller-level [ProducesResponseType(typeof(DerivedModel), 404)] + // is therefore allowed to fill in the entry. This locks down behavior for the corner case + // where a custom provider intentionally yields no type information. + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetUser)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new NullTypeMetadataProvider(404), FilterScope.Action)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(DerivedModel), 404), FilterScope.Controller)); + + // Act + var provider = GetProvider(); + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert — controller-level (404, DerivedModel) survives because the higher-scope + // null-Type provider produced no entry. + Assert.Contains(result, r => r.StatusCode == 404 && r.Type == typeof(DerivedModel)); + } + + private sealed class NullTypeMetadataProvider : IApiResponseMetadataProvider + { + public NullTypeMetadataProvider(int statusCode) { StatusCode = statusCode; } + public Type Type => null; + public int StatusCode { get; } + public string Description => null; + public void SetContentTypes(MediaTypeCollection contentTypes) { } + } + + public class VoidController : ControllerBase + { + public Task Delete() => Task.CompletedTask; + } + public static class SearchApiConventions { [ProducesResponseType(206)] diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 9a94a13bf668..691146128fcb 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -275,6 +275,71 @@ public void AddsMultipleResponseFormatsFromMetadataWithIResult() Assert.Empty(badRequestResponseType.ApiResponseFormats); } + [Fact] + public void PreservesMultipleProducesResponseTypesWithSameStatusCodeButDifferentContentTypes() + { + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/html")] + () => Results.Ok() + ); + + Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); + + var jsonResponseType = apiDescription.SupportedResponseTypes.Single(r => r.Type == typeof(InferredJsonClass)); + Assert.Equal(200, jsonResponseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), jsonResponseType.Type); + Assert.Equal("application/json", Assert.Single(jsonResponseType.ApiResponseFormats).MediaType); + + var htmlResponseType = apiDescription.SupportedResponseTypes.Single(r => r.Type == typeof(string)); + Assert.Equal(200, htmlResponseType.StatusCode); + Assert.Equal(typeof(string), htmlResponseType.Type); + Assert.Equal("text/html", Assert.Single(htmlResponseType.ApiResponseFormats).MediaType); + } + + [Fact] + public void PreservesMultipleProducesResponseTypesWithSameStatusCodeButDifferentContentTypes_WithParametersAndAnonymousObjectResult() + { + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/html")] + (int id, string name) => Results.Ok(new { Id = id, Name = name }), + "/{id}" + ); + + Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); + + var jsonResponseType = apiDescription.SupportedResponseTypes.Single(r => r.Type == typeof(InferredJsonClass)); + Assert.Equal(200, jsonResponseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), jsonResponseType.Type); + Assert.Equal("application/json", Assert.Single(jsonResponseType.ApiResponseFormats).MediaType); + + var htmlResponseType = apiDescription.SupportedResponseTypes.Single(r => r.Type == typeof(string)); + Assert.Equal(200, htmlResponseType.StatusCode); + Assert.Equal(typeof(string), htmlResponseType.Type); + Assert.Equal("text/html", Assert.Single(htmlResponseType.ApiResponseFormats).MediaType); + } + + [Fact] + public void PreservesMultipleProducesResponseTypesWithSameStatusCodeButDifferentTypesWithoutContentTypes() + { + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(InferredJsonClass), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + () => Results.Ok(new { Value = 1 }) + ); + + Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); + + var inferredJsonResponseType = apiDescription.SupportedResponseTypes.Single(r => r.Type == typeof(InferredJsonClass)); + Assert.Equal(200, inferredJsonResponseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), inferredJsonResponseType.Type); + + var stringResponseType = apiDescription.SupportedResponseTypes.Single(r => r.Type == typeof(string)); + Assert.Equal(200, stringResponseType.StatusCode); + Assert.Equal(typeof(string), stringResponseType.Type); + } + [Fact] public void AddsMultipleResponseFormatsForTypedResults() { @@ -479,27 +544,33 @@ public void WithEmptyMethodBody_AddsResponseDescription() } /// - /// Setting the description grabs the LAST description. - // To validate this, we add multiple ProducesResponseType to validate that it only grabs the LAST ONE. + /// Description policy for the (StatusCode, Type) pair when multiple ProducesResponseType + /// attributes match: descriptions are merged /// [Fact] - public void AddsResponseDescription_UsesLastOne() + public void AddsResponseDescription_ConcatenatesOnMerge_LastWinsForFallback() { const string expectedCreatedDescription = "A new item was created"; - const string expectedBadRequestDescription = "Validation failed for the request"; + const string firstBadRequestDescription = "Validation failed for the request"; + const string secondBadRequestDescription = "Last description for bad request"; + var expectedBadRequestDescription = firstBadRequestDescription + "\n\n" + secondBadRequestDescription; var apiDescription = GetApiDescription( - [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "First description")] // The first item is an int, not a timespan, shouldn't match - [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "Second description")] // Not a timespan AND not the final item, shouldn't match - [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] // This is the last item, which should match - [ProducesResponseType(StatusCodes.Status400BadRequest, Description = "First description")] - [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] - () => TypedResults.Created("https://example.com", new TimeSpan())); + [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "First description")] + [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "Second description")] + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = firstBadRequestDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = secondBadRequestDescription)] + () => TypedResults.Created("https://example.com", new TimeSpan())); Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); var createdResponseType = apiDescription.SupportedResponseTypes[0]; + // For status 201, only the (201, TimeSpan) entry survives merging (TypedResults.Created + // claims status 201 from endpoint metadata, dropping the (201, int) attribute entries) and + // type-compatibility filtering then picks the matching description from the dropped entries + // via the GetMatchingResponseTypeDescription fallback (which keeps the LAST match). Assert.Equal(201, createdResponseType.StatusCode); Assert.Equal(typeof(TimeSpan), createdResponseType.Type); Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType); @@ -510,6 +581,8 @@ public void AddsResponseDescription_UsesLastOne() var badRequestResponseType = apiDescription.SupportedResponseTypes[1]; + // For status 400 (no endpoint claim), both attribute entries are merged and their + // descriptions are concatenated with a Markdown paragraph break per MergeApiResponse. Assert.Equal(400, badRequestResponseType.StatusCode); Assert.Equal(typeof(void), badRequestResponseType.Type); Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType); @@ -1116,8 +1189,20 @@ public void RespectsProducesWithGroupNameExtensionMethod() // Assert var apiDescription = Assert.Single(context.Results); - var responseTypes = Assert.Single(apiDescription.SupportedResponseTypes); - Assert.Equal(typeof(InferredJsonClass), responseTypes.Type); + Assert.Collection( + apiDescription.SupportedResponseTypes, + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); + }); Assert.Equal(endpointGroupName, apiDescription.GroupName); } @@ -1168,30 +1253,36 @@ public void HandlesProducesWithProducesProblem() // Assert Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + context.Results.SelectMany(r => r.SupportedResponseTypes), responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); Assert.Equal(200, responseType.StatusCode); - Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); }, responseType => { Assert.Equal(typeof(HttpValidationProblemDetails), responseType.Type); Assert.Equal(400, responseType.StatusCode); - Assert.Equal(new[] { "application/problem+json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/problem+json"], GetSortedMediaTypes(responseType)); }, responseType => { Assert.Equal(typeof(ProblemDetails), responseType.Type); Assert.Equal(404, responseType.StatusCode); - Assert.Equal(new[] { "application/problem+json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/problem+json"], GetSortedMediaTypes(responseType)); }, responseType => { Assert.Equal(typeof(ProblemDetails), responseType.Type); Assert.Equal(409, responseType.StatusCode); - Assert.Equal(new[] { "application/problem+json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/problem+json"], GetSortedMediaTypes(responseType)); }); } @@ -1218,21 +1309,165 @@ public void HandleMultipleProduces() // Assert Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + context.Results.SelectMany(r => r.SupportedResponseTypes), responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); Assert.Equal(200, responseType.StatusCode); - Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); }, responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); Assert.Equal(201, responseType.StatusCode); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void HandleMultipleProducesWithSameStatusCodeAndDifferentContentTypes() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "text/html"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + Assert.Collection( + context.Results.SelectMany(r => r.SupportedResponseTypes), + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/html"], GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void HandleMultipleProducesWithSameStatusCodeAndDifferentTypesWithoutContentType() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status200OK); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + Assert.Collection( + context.Results.SelectMany(r => r.SupportedResponseTypes), + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); }); } + [Fact] + public void HandleMultipleProducesWithSameStatusCodeAndTypeMergesContentTypes() + { + // Same (StatusCode, Type) with different content types → merge into single entry with multiple content types + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "text/xml"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert — single entry for (200, InferredJsonClass) with both content types merged + var responseType = Assert.Single(context.Results.SelectMany(r => r.SupportedResponseTypes)); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); + } + + [Fact] + public void HandleMultipleProducesDeterministicOrdering() + { + // Deterministic ordering in complex scenario — multiple types, status codes, and merged content types + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "text/xml") + .Produces(StatusCodes.Status200OK, "text/plain") + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status201Created, "application/json"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert — ordered by StatusCode, then by Type name, content types merged for same (StatusCode, Type) + Assert.Collection( + context.Results.SelectMany(r => r.SupportedResponseTypes), + responseType => + { + // (200, InferredJsonClass) — merged from two .Produces calls + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + // (200, string) + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + // (201, InferredJsonClass) + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + // (404, void) + Assert.Equal(typeof(void), responseType.Type); + Assert.Equal(404, responseType.StatusCode); + }); + } + [Fact] public void HandleAcceptsMetadata() { @@ -1318,15 +1553,108 @@ public void FavorsProducesMetadataOverAttribute() // Assert Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + context.Results.SelectMany(r => r.SupportedResponseTypes), responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); Assert.Equal(200, responseType.StatusCode); - Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void RouteGroup_AndRouteSpecific_SameStatusCodeAndType_MergesContentTypes() + { + // Route group adds (200, InferredJsonClass, "application/json"), + // route-specific adds (200, InferredJsonClass, "text/xml"). + // Same (StatusCode, Type) → content types merge into a single entry. + + var builder = CreateBuilder(); + var group = builder.MapGroup("/api") + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(InferredJsonClass), ["application/json"])); + group.MapGet("/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "text/xml"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var responseType = Assert.Single(context.Results.SelectMany(r => r.SupportedResponseTypes)); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); + } + + [Fact] + public void RouteGroup_AndRouteSpecific_SameStatusCodeDifferentType_BothCoexist() + { + // Route group adds (200, InferredJsonClass, "application/json"), + // route-specific adds (200, string, "text/plain"). + // Different types at same status code → both coexist. + + var builder = CreateBuilder(); + var group = builder.MapGroup("/api") + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(InferredJsonClass), ["application/json"])); + group.MapGet("/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "text/plain"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + Assert.Collection( + context.Results.SelectMany(r => r.SupportedResponseTypes), + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); }); } + [Fact] + public void RouteGroup_AndRouteSpecific_IdenticalMetadata_SingleEntry() + { + // Route group and route-specific both add (200, InferredJsonClass, "application/json"). + // Identical (StatusCode, Type) → merged into a single entry, not duplicated. + + var builder = CreateBuilder(); + var group = builder.MapGroup("/api") + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(InferredJsonClass), ["application/json"])); + group.MapGet("/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var responseType = Assert.Single(context.Results.SelectMany(r => r.SupportedResponseTypes)); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + } + [Fact] public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() { diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 93803212f4b3..ca93bfcdeee8 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -59,6 +59,27 @@ public ActionResult UnsupportedHttpMethod() public ActionResult HttpQueryWithBodyMethod([FromBody] MvcTodo todo) => Ok(todo); + [HttpGet] + [Route("/multi-content-type")] + [ProducesResponseType(typeof(MvcTodo), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")] + public IActionResult GetMultiContentType() + => Ok(new MvcTodo("Title", "Description", true)); + + [HttpGet] + [Route("/any-of")] + [ProducesResponseType(typeof(MvcTodo), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(CurrentWeather), StatusCodes.Status200OK, "application/json")] + public IActionResult GetAnyOf() + => Ok(new MvcTodo("Title", "Description", true)); + + [HttpGet] + [Route("/dup-description")] + [ProducesResponseType(typeof(MvcTodo), StatusCodes.Status200OK, "application/json", Description = "Use it!")] + [ProducesResponseType(typeof(MvcTodo), StatusCodes.Status200OK, Description = "Returns a Todo")] + public IActionResult GetDuplicateDescription() + => Ok(new MvcTodo("Title", "Description", true)); + public class HttpQuery() : HttpMethodAttribute(["QUERY"]); public class HttpFoo() : HttpMethodAttribute(["FOO"]); diff --git a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs index b2f9cac4f6a6..fdee7ad60f3e 100644 --- a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs @@ -11,9 +11,23 @@ public static IEndpointRouteBuilder MapResponseEndpoints(this IEndpointRouteBuil responses.MapGet("/200-add-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now)) .Produces(additionalContentTypes: "text/xml"); + responses.MapGet("/200-add-xml-results", () => Results.Ok(new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))) + .Produces(additionalContentTypes: "text/xml"); + responses.MapGet("/200-only-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now)) .Produces(contentType: "text/xml"); + responses.MapGet("/200-only-xml-results", () => Results.Ok(new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))) + .Produces(contentType: "text/xml"); + + responses.MapGet("/200-multi-content-type", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "text/xml"); + + responses.MapGet("/200-any-of", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "application/json"); + responses.MapGet("/triangle", () => new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }); responses.MapGet("/shape", Shape () => new Triangle { Color = "blue", Sides = 4 }); diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 89a33513afa6..c45b76e82b67 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -387,22 +387,26 @@ private async Task GetResponsesAsync( { return new OpenApiResponses { - ["200"] = await GetResponseAsync(document, description, StatusCodes.Status200OK, _defaultApiResponseType, scopedServiceProvider, schemaTransformers, cancellationToken) + ["200"] = await GetResponseAsync(document, description, StatusCodes.Status200OK, [_defaultApiResponseType], scopedServiceProvider, schemaTransformers, cancellationToken) }; } + // Group response types by their response key so that multiple ApiResponseType entries + // sharing the same status code are merged into a single OpenApiResponse. This supports + // scenarios where different Produces attributes specify different content-types or + // different CLR types for the same HTTP status code. var responses = new OpenApiResponses(); - foreach (var responseType in description.SupportedResponseTypes) - { - // The "default" response type is a special case in OpenAPI used to describe - // the response for all HTTP status codes that are not explicitly defined - // for a given operation. This is typically used to describe catch-all scenarios - // like error responses. - var responseKey = responseType.IsDefaultResponse + var groupedResponseTypes = description.SupportedResponseTypes + .GroupBy(r => r.IsDefaultResponse ? OpenApiConstants.DefaultOpenApiResponseKey - : responseType.StatusCode.ToString(CultureInfo.InvariantCulture); - responses.Add(responseKey, await GetResponseAsync(document, description, responseType.StatusCode, responseType, scopedServiceProvider, schemaTransformers, cancellationToken)); + : r.StatusCode.ToString(CultureInfo.InvariantCulture)); + + foreach (var group in groupedResponseTypes) + { + var statusCode = group.First().StatusCode; + responses[group.Key] = await GetResponseAsync(document, description, statusCode, group.ToList(), scopedServiceProvider, schemaTransformers, cancellationToken); } + return responses; } @@ -410,34 +414,60 @@ private async Task GetResponseAsync( OpenApiDocument document, ApiDescription apiDescription, int statusCode, - ApiResponseType apiResponseType, + IReadOnlyList apiResponseTypes, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) { + var description = apiResponseTypes.Select(r => r.Description).FirstOrDefault(d => d is not null); var response = new OpenApiResponse { - Description = apiResponseType.Description ?? ReasonPhrases.GetReasonPhrase(statusCode), + Description = description ?? ReasonPhrases.GetReasonPhrase(statusCode), Content = new Dictionary() }; - // ApiResponseFormats aggregates information about the supported response content types - // from different types of Produces metadata. This is handled by ApiExplorer so looking - // up values in ApiResponseFormats should provide us a complete set of the information - // encoded in Produces metadata added via attributes or extension methods. - var apiResponseFormatContentTypes = apiResponseType.ApiResponseFormats - .Select(responseFormat => responseFormat.MediaType); - foreach (var contentType in apiResponseFormatContentTypes) + // Collect schemas per content-type across all ApiResponseType entries in this group. + // When multiple entries contribute different schemas for the same content-type, they + // will be merged into an anyOf composite schema. + var schemasByContentType = new Dictionary>(); + + foreach (var apiResponseType in apiResponseTypes) { - IOpenApiSchema? schema = null; - if (apiResponseType.Type is { } responseType) + // ApiResponseFormats aggregates information about the supported response content types + // from different types of Produces metadata. This is handled by ApiExplorer so looking + // up values in ApiResponseFormats should provide us a complete set of the information + // encoded in Produces metadata added via attributes or extension methods. + var apiResponseFormatContentTypes = apiResponseType.ApiResponseFormats + .Select(responseFormat => responseFormat.MediaType); + foreach (var contentType in apiResponseFormatContentTypes) { - schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken); - schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription) - ? schema.CreateOneOfNullableWrapper() - : schema; + IOpenApiSchema? schema = null; + if (apiResponseType.Type is { } responseType) + { + schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken); + schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription) + ? schema.CreateOneOfNullableWrapper() + : schema; + } + + schema ??= new OpenApiSchema(); + + if (!schemasByContentType.TryGetValue(contentType, out var schemas)) + { + schemas = []; + schemasByContentType[contentType] = schemas; + } + + schemas.Add(schema); } - response.Content[contentType] = new OpenApiMediaType { Schema = schema ?? new OpenApiSchema() }; + } + + foreach (var (contentType, schemas) in schemasByContentType) + { + IOpenApiSchema finalSchema = schemas.Count == 1 + ? schemas[0] + : new OpenApiSchema { AnyOf = [.. schemas] }; + response.Content[contentType] = new OpenApiMediaType { Schema = finalSchema }; } // MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index c148c897cdf2..767a26b50d05 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -206,6 +206,75 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/any-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } + }, + "/dup-description": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "Use it!\n\nReturns a Todo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 96a3be6747cf..aa68f741e0aa 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -6,6 +6,37 @@ }, "paths": { "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-add-xml-results": { "get": { "tags": [ "Sample" @@ -42,6 +73,80 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-only-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-multi-content-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-any-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } } } } @@ -182,6 +287,42 @@ }, "description": "Represents a to-do item." }, + "TodoWithDueDate": { + "required": [ + "dueDate", + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "dueDate": { + "type": "string", + "description": "The due date of the to-do item.", + "format": "date-time" + }, + "id": { + "type": "integer", + "description": "The unique identifier of the to-do item.", + "format": "int32" + }, + "title": { + "type": "string", + "description": "The title of the to-do item." + }, + "completed": { + "type": "boolean", + "description": "Indicates whether the to-do item is completed." + }, + "createdAt": { + "type": "string", + "description": "The date and time when the to-do item was created.", + "format": "date-time" + } + }, + "description": "Represents a to-do item with a due date." + }, "Triangle": { "type": "object", "properties": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 9677247fb9aa..b2d056690a11 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -206,6 +206,75 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/any-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } + }, + "/dup-description": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "Use it!\n\nReturns a Todo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 4ecf3886a25f..c2ae5c77288b 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -6,6 +6,37 @@ }, "paths": { "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-add-xml-results": { "get": { "tags": [ "Sample" @@ -42,6 +73,80 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-only-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-multi-content-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-any-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } } } } @@ -182,6 +287,42 @@ }, "description": "Represents a to-do item." }, + "TodoWithDueDate": { + "required": [ + "dueDate", + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "dueDate": { + "type": "string", + "description": "The due date of the to-do item.", + "format": "date-time" + }, + "id": { + "type": "integer", + "description": "The unique identifier of the to-do item.", + "format": "int32" + }, + "title": { + "type": "string", + "description": "The title of the to-do item." + }, + "completed": { + "type": "boolean", + "description": "Indicates whether the to-do item is completed." + }, + "createdAt": { + "type": "string", + "description": "The date and time when the to-do item was created.", + "format": "date-time" + } + }, + "description": "Represents a to-do item with a due date." + }, "Triangle": { "type": "object", "properties": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 49a6f4a92768..569657ee3707 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -202,6 +202,75 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/any-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } + }, + "/dup-description": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "Use it!\n\nReturns a Todo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index b4766a0d2cbf..5001146b9390 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -6,6 +6,37 @@ }, "paths": { "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-add-xml-results": { "get": { "tags": [ "Sample" @@ -42,6 +73,80 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-only-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-multi-content-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-any-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } } } } @@ -182,6 +287,42 @@ }, "description": "Represents a to-do item." }, + "TodoWithDueDate": { + "required": [ + "dueDate", + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "dueDate": { + "type": "string", + "description": "The due date of the to-do item.", + "format": "date-time" + }, + "id": { + "type": "integer", + "description": "The unique identifier of the to-do item.", + "format": "int32" + }, + "title": { + "type": "string", + "description": "The title of the to-do item." + }, + "completed": { + "type": "boolean", + "description": "Indicates whether the to-do item is completed." + }, + "createdAt": { + "type": "string", + "description": "The date and time when the to-do item was created.", + "format": "date-time" + } + }, + "description": "Represents a to-do item with a due date." + }, "Triangle": { "type": "object", "properties": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index 03d8d6e71007..a4b193822a4e 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1318,6 +1318,37 @@ } }, "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-add-xml-results": { "get": { "tags": [ "Sample" @@ -1354,6 +1385,80 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-only-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-multi-content-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/responses/200-any-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } } } } @@ -1599,6 +1704,75 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/any-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } + }, + "/dup-description": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "Use it!\n\nReturns a Todo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs index 01f7ddff1df0..e277f980e69d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs @@ -106,7 +106,9 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("OK", response.Value.Description); var content = Assert.Single(response.Value.Content); Assert.Equal("application/json", content.Key); - // Todo: Check that this generates a schema using `oneOf`. + var schema = content.Value.Schema; + Assert.NotNull(schema.AnyOf); + Assert.Equal(2, schema.AnyOf.Count); }); } @@ -158,6 +160,10 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("200", response.Key); Assert.Equal("OK", response.Value.Description); Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/json", content.Key); + }, content => { Assert.Equal("application/xml", content.Key); @@ -439,4 +445,138 @@ await VerifyOpenApiDocument(builder, document => }); }); } + + [Fact] + public async Task GetOpenApiResponse_MergesMultipleTypesForSameContentTypeAndDifferentContentTypes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Error), ["text/plain"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal(2, response.Value.Content.Count); + + // application/json should have an anyOf schema since two types share the same content-type + Assert.True(response.Value.Content.TryGetValue("application/json", out var jsonContent)); + Assert.NotNull(jsonContent.Schema.AnyOf); + Assert.Equal(2, jsonContent.Schema.AnyOf.Count); + + // text/plain should have its own schema without anyOf + Assert.True(response.Value.Content.TryGetValue("text/plain", out var textContent)); + Assert.Null(textContent.Schema.AnyOf); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsThreeTypesForSameContentTypeWithAnyOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Error), ["application/json"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var content = Assert.Single(response.Value.Content); + Assert.Equal("application/json", content.Key); + Assert.NotNull(content.Value.Schema.AnyOf); + Assert.Equal(3, content.Value.Schema.AnyOf.Count); + }); + } + + [Fact] + public async Task GetOpenApiResponse_MultipleProducesWithDifferentStatusCodes_ProducesSeparateResponses() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Error), ["text/plain"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status404NotFound, typeof(Error), ["application/json"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + Assert.Equal(2, operation.Responses.Count); + + // 200 response should have both content types merged + Assert.True(operation.Responses.TryGetValue("200", out var okResponse)); + Assert.Equal(2, okResponse.Content.Count); + Assert.True(okResponse.Content.ContainsKey("application/json")); + Assert.True(okResponse.Content.ContainsKey("text/plain")); + + // 404 response is separate + Assert.True(operation.Responses.TryGetValue("404", out var notFoundResponse)); + var notFoundContent = Assert.Single(notFoundResponse.Content); + Assert.Equal("application/json", notFoundContent.Key); + }); + } + + [Fact] + public async Task GetOpenApiResponse_ProducesExtensionMethod_SupportsDifferentTypesForSameStatusCode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "text/plain"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal(2, response.Value.Content.Count); + Assert.True(response.Value.Content.ContainsKey("application/json")); + Assert.True(response.Value.Content.ContainsKey("text/plain")); + }); + } + + [Fact] + public async Task GetOpenApiResponse_ProducesExtensionMethod_SupportsAnyOfForSameContentType() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status200OK, "application/json"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var content = Assert.Single(response.Value.Content); + Assert.Equal("application/json", content.Key); + Assert.NotNull(content.Value.Schema.AnyOf); + Assert.Equal(2, content.Value.Schema.AnyOf.Count); + }); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs index 9ab90d9f52a0..293c36ec0703 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -242,14 +243,30 @@ public ControllerActionDescriptor CreateActionDescriptor(string methodName = nul .SelectMany(a => a.HttpMethods) .DefaultIfEmpty("GET") )]; + + var actionFilters = action.MethodInfo.GetCustomAttributes() + .OfType() + .Select(f => new FilterDescriptor(f, FilterScope.Action)); + + var controllerFilters = Enumerable.Empty(); if (controllerType is not null) { foreach (var attribute in controllerType.GetCustomAttributes()) { action.EndpointMetadata.Add(attribute); } + + controllerFilters = controllerType.GetCustomAttributes() + .OfType() + .Select(f => new FilterDescriptor(f, FilterScope.Controller)); } + action.FilterDescriptors = actionFilters + .Concat(controllerFilters) + .OrderBy(d => d.Order) + .ThenBy(d => d.Scope) + .ToList(); + action.Parameters = []; foreach (var parameter in action.MethodInfo.GetParameters()) { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index 7cf014d29290..055b872972d1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs @@ -1125,6 +1125,39 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiResponse_MvcController_SupportsMultipleResponseTypesForSameStatusCode() + { + var actionDescriptor = CreateActionDescriptor(nameof(MultiProducesController.GetMultiContentType), typeof(MultiProducesController)); + + await VerifyOpenApiDocument(actionDescriptor, document => + { + var operation = document.Paths["/multi"].Operations[HttpMethod.Get]; + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal(2, response.Value.Content.Count); + Assert.True(response.Value.Content.ContainsKey("application/json")); + Assert.True(response.Value.Content.ContainsKey("text/plain")); + }); + } + + [Fact] + public async Task GetOpenApiResponse_MvcController_SupportsAnyOfForSameContentType() + { + var actionDescriptor = CreateActionDescriptor(nameof(MultiProducesController.GetAnyOf), typeof(MultiProducesController)); + + await VerifyOpenApiDocument(actionDescriptor, document => + { + var operation = document.Paths["/anyOf"].Operations[HttpMethod.Get]; + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var content = Assert.Single(response.Value.Content); + Assert.Equal("application/json", content.Key); + Assert.NotNull(content.Value.Schema.AnyOf); + Assert.Equal(2, content.Value.Schema.AnyOf.Count); + }); + } + [ApiController] [Produces("application/json")] public class TestController @@ -1134,6 +1167,22 @@ public class TestController internal Todo Get() => new(1, "Write test", false, DateTime.Now); } + [ApiController] + public class MultiProducesController + { + [HttpGet] + [Route("/multi")] + [ProducesResponseType(typeof(Todo), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")] + internal IActionResult GetMultiContentType() => throw new NotImplementedException(); + + [HttpGet] + [Route("/anyOf")] + [ProducesResponseType(typeof(Todo), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(Error), StatusCodes.Status200OK, "application/json")] + internal IActionResult GetAnyOf() => throw new NotImplementedException(); + } + private class ClassWithObjectProperty { public object Object { get; set; }