From 0f7cefbddca03665c1cb47bed48f4d8977bed95c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 3 Mar 2026 14:14:53 +0100 Subject: [PATCH 01/41] api explorer support --- .../src/ApiResponseTypeProvider.cs | 29 ++-- .../EndpointMetadataApiDescriptionProvider.cs | 24 +++- .../test/ApiResponseTypeProviderTest.cs | 89 ++++++++++++ ...pointMetadataApiDescriptionProviderTest.cs | 127 ++++++++++++++++++ 4 files changed, 258 insertions(+), 11 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 7bdaaad11dca..ec48bdd6fd6d 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -13,6 +13,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer; internal sealed class ApiResponseTypeProvider { + internal readonly record struct ResponseKey(int StatusCode, Type? DeclaredType, string? ContentType); + private readonly IModelMetadataProvider _modelMetadataProvider; private readonly IActionResultTypeMapper _mapper; private readonly MvcOptions _mvcOptions; @@ -89,7 +91,7 @@ private ICollection GetApiResponseTypes( // Read response metadata from providers and // overwrite responseTypes from the metadata based - // on the status code + // on the status code and content type var responseTypesFromProvider = ReadResponseMetadata( responseMetadataAttributes, type, @@ -106,7 +108,8 @@ private ICollection GetApiResponseTypes( // Set the default status only when no status has already been set explicitly if (responseTypes.Count == 0 && type != null) { - responseTypes.Add(StatusCodes.Status200OK, new ApiResponseType + var defaultKey = new ResponseKey(StatusCodes.Status200OK, type, null); + responseTypes.Add(defaultKey, new ApiResponseType { StatusCode = StatusCodes.Status200OK, Type = type, @@ -128,11 +131,15 @@ 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( + internal static Dictionary ReadResponseMetadata( IReadOnlyList responseMetadataAttributes, Type? type, Type? defaultErrorType, @@ -142,7 +149,7 @@ internal static Dictionary ReadResponseMetadata( IModelMetadataProvider? modelMetadataProvider = null) { errorSetByDefault = false; - var results = new Dictionary(); + var results = new Dictionary(); // Get the content type that the action explicitly set to support. // Walk through all 'filter' attributes in order, and allow each one to see or override @@ -204,16 +211,19 @@ internal static Dictionary ReadResponseMetadata( // action/controller/etc. In that scenario, instead of picking the most-specific // set of content types (like we do with the Produces attribute above) we process // the content types for each attribute independently. + string? keyContentType = null; if (metadataAttribute is ProducesResponseTypeAttribute) { var attributeContentTypes = new MediaTypeCollection(); metadataAttribute.SetContentTypes(attributeContentTypes); CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider); + keyContentType = attributeContentTypes.FirstOrDefault(); } if (apiResponseType.Type != null) { - results[apiResponseType.StatusCode] = apiResponseType; + var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, keyContentType); + results[key] = apiResponseType; } } } @@ -221,13 +231,13 @@ internal static Dictionary ReadResponseMetadata( return results; } - internal static Dictionary ReadResponseMetadata( + internal static Dictionary ReadResponseMetadata( IReadOnlyList responseMetadata, Type? type, IEnumerable? responseTypeMetadataProviders = null, IModelMetadataProvider? modelMetadataProvider = null) { - var results = new Dictionary(); + var results = new Dictionary(); foreach (var metadata in responseMetadata) { @@ -270,7 +280,8 @@ internal static Dictionary ReadResponseMetadata( if (apiResponseType.Type != null) { - results[apiResponseType.StatusCode] = apiResponseType; + var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, metadata.ContentTypes?.FirstOrDefault()); + results[key] = apiResponseType; } } diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 0b40e8813269..4e2a8a0d2650 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -346,7 +346,9 @@ private static void AddSupportedResponseTypes( // 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); + 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 +379,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 +403,21 @@ private static void AddSupportedResponseTypes( supportedResponseTypes.Add(defaultApiResponseType); } + if (supportedResponseTypes.Count > 1) + { + var orderedSupportedResponseTypes = supportedResponseTypes + .OrderBy(responseType => responseType.StatusCode) + .ThenBy(responseType => responseType.Type?.Name) + .ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType) + .ToList(); + + supportedResponseTypes.Clear(); + foreach (var orderedSupportedResponseType in orderedSupportedResponseTypes) + { + supportedResponseTypes.Add(orderedSupportedResponseType); + } + } + 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..b31e23a26a35 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -63,6 +63,19 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresent() }); } + [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.Equal(2, result.Count); + Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(BaseModel)); + Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(string)); + } + [ApiConventionType(typeof(DefaultApiConventions))] public class GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController : ControllerBase { @@ -889,6 +902,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 +929,78 @@ 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.OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + 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.OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + 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 static class SearchApiConventions { [ProducesResponseType(206)] diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index b58fbefa53b6..db0afa9918c1 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -274,6 +274,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() { @@ -1232,6 +1297,68 @@ public void HandleMultipleProduces() }); } + [Fact] + public void HandleMultipleProducesWithSameStatusCodeAndDifferentContentTypes() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => "") + .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(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "text/html" }, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void HandleMultipleProducesWithSameStatusCodeAndDifferentTypesWithoutContentType() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => "") + .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 HandleAcceptsMetadata() { From c97f70ba8c50cb27275db71db287d35b92a0336f Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 3 Mar 2026 16:59:41 +0100 Subject: [PATCH 02/41] api explorer --- .../src/ApiResponseTypeProvider.cs | 34 +++++++ .../test/ApiResponseTypeProviderTest.cs | 90 +++++++++---------- ...pointMetadataApiDescriptionProviderTest.cs | 4 +- 3 files changed, 78 insertions(+), 50 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index ec48bdd6fd6d..246a6c11ce70 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -100,6 +100,22 @@ private ICollection GetApiResponseTypes( out var _, responseTypeMetadataProviders); + var responseProviderStatusCodes = responseTypesFromProvider.Values + .Select(responseType => responseType.StatusCode) + .ToHashSet(); + + // Preserve existing source precedence: when metadata providers/attributes define a given status code, + // entries for that status code discovered from endpoint metadata are removed before merge. + // This keeps provider metadata authoritative per status code while still allowing multiple provider + // entries for the same status code when their keys differ (type/content-type). + foreach (var existingResponseType in responseTypes.Keys.ToList()) + { + if (responseProviderStatusCodes.Contains(existingResponseType.StatusCode)) + { + responseTypes.Remove(existingResponseType); + } + } + foreach (var responseType in responseTypesFromProvider) { responseTypes[responseType.Key] = responseType.Value; @@ -280,6 +296,24 @@ internal static Dictionary ReadResponseMetadata( if (apiResponseType.Type != null) { + // If metadata explicitly specifies a different type for this status code than the inferred + // return type, drop the inferred entry for that status code. This preserves long-standing + // behavior where explicit metadata takes precedence over inference while still allowing + // multiple explicit entries for the same status code. + if (type != null && + type != typeof(void) && + apiResponseType.Type != type) + { + foreach (var existingResponseKey in results.Keys.ToList()) + { + if (existingResponseKey.StatusCode == apiResponseType.StatusCode && + existingResponseKey.DeclaredType == type) + { + results.Remove(existingResponseKey); + } + } + } + var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, metadata.ContentTypes?.FirstOrDefault()); results[key] = apiResponseType; } diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index b31e23a26a35..cdc8314e4783 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -63,19 +63,6 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresent() }); } - [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.Equal(2, result.Count); - Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(BaseModel)); - Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(string)); - } - [ApiConventionType(typeof(DefaultApiConventions))] public class GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController : ControllerBase { @@ -110,41 +97,35 @@ public void GetApiResponseTypes_CombinesFilters() var result = provider.GetApiResponseTypes(actionDescriptor); // Assert - Assert.Collection( - result.OrderBy(r => r.StatusCode), - responseType => - { - Assert.Equal(201, responseType.StatusCode); - Assert.Equal(typeof(BaseModel), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Collection( - responseType.ApiResponseFormats, - format => - { - Assert.Equal("application/json", format.MediaType); - Assert.IsType(format.Formatter); - }); - }, - responseType => - { - Assert.Equal(400, responseType.StatusCode); - Assert.Equal(typeof(ProblemDetails), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Collection( - responseType.ApiResponseFormats, - format => - { - Assert.Equal("application/json", format.MediaType); - Assert.IsType(format.Formatter); - }); - }, - responseType => - { - Assert.Equal(404, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }); + Assert.Equal(5, result.Count); + + Assert.Contains(result, responseType => + responseType.StatusCode == 201 && + responseType.Type == typeof(object) && + responseType.ApiResponseFormats.Count == 1 && + responseType.ApiResponseFormats[0].MediaType == "application/json"); + + Assert.Contains(result, responseType => + responseType.StatusCode == 201 && + responseType.Type == typeof(BaseModel) && + responseType.ApiResponseFormats.Count == 1 && + responseType.ApiResponseFormats[0].MediaType == "application/json"); + + Assert.Contains(result, responseType => + responseType.StatusCode == 400 && + responseType.Type == typeof(ProblemDetails) && + responseType.ApiResponseFormats.Count == 1 && + responseType.ApiResponseFormats[0].MediaType == "application/json"); + + Assert.Contains(result, responseType => + responseType.StatusCode == 400 && + responseType.Type == typeof(void) && + responseType.ApiResponseFormats.Count == 0); + + Assert.Contains(result, responseType => + responseType.StatusCode == 404 && + responseType.Type == typeof(void) && + responseType.ApiResponseFormats.Count == 0); } [Fact] @@ -823,6 +804,19 @@ 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.Equal(2, result.Count); + Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(BaseModel)); + Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(string)); + } + [Fact] public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithBuiltIResultReturnType() { diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index db0afa9918c1..b55980db4d8e 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1301,7 +1301,7 @@ public void HandleMultipleProduces() public void HandleMultipleProducesWithSameStatusCodeAndDifferentContentTypes() { var builder = CreateBuilder(); - builder.MapGet("/api/todos", () => "") + builder.MapGet("/api/todos", () => Results.Ok()) .Produces(StatusCodes.Status200OK, "application/json") .Produces(StatusCodes.Status200OK, "text/html"); var context = new ApiDescriptionProviderContext(Array.Empty()); @@ -1332,7 +1332,7 @@ public void HandleMultipleProducesWithSameStatusCodeAndDifferentContentTypes() public void HandleMultipleProducesWithSameStatusCodeAndDifferentTypesWithoutContentType() { var builder = CreateBuilder(); - builder.MapGet("/api/todos", () => "") + builder.MapGet("/api/todos", () => Results.Ok()) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK); var context = new ApiDescriptionProviderContext(Array.Empty()); From 7b4f6aa136148742c975080dcda68bdb2c16f2fa Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 4 Mar 2026 15:52:24 +0100 Subject: [PATCH 03/41] mvc support --- .../sample/Controllers/TestController.cs | 14 ++++ .../sample/Endpoints/MapResponsesEndpoints.cs | 8 ++ .../src/Services/OpenApiDocumentService.cs | 82 +++++++++++++------ .../OpenApiDocumentServiceTests.Responses.cs | 8 +- 4 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 43a1e22250f9..398cce1feedb 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -54,6 +54,20 @@ public ActionResult HttpQueryMethod() public ActionResult UnsupportedHttpMethod() => Ok(new CurrentWeather(100)); + [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("/one-of")] + [ProducesResponseType(typeof(MvcTodo), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(CurrentWeather), StatusCodes.Status200OK, "application/json")] + public IActionResult GetOneOf() + => 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..34cbd978bf16 100644 --- a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs @@ -14,6 +14,14 @@ public static IEndpointRouteBuilder MapResponseEndpoints(this IEndpointRouteBuil responses.MapGet("/200-only-xml", () => 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-one-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..1332633eee7a 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 a oneOf 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 { OneOf = [.. 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/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs index 01f7ddff1df0..04f6bf2919d5 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.OneOf); + Assert.Equal(2, schema.OneOf.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); From dc11280fba07fe7f5872b1b06a8e119849f0947f Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 4 Mar 2026 16:35:04 +0100 Subject: [PATCH 04/41] openapi oneof --- .../OpenApiDocumentServiceTests.Responses.cs | 134 ++++++++++++++++++ .../OpenApiDocumentServiceTestsBase.cs | 6 + .../OpenApiSchemaService.ResponseSchemas.cs | 49 +++++++ 3 files changed, 189 insertions(+) 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 04f6bf2919d5..4059f6420515 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 @@ -445,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 a oneOf schema since two types share the same content-type + Assert.True(response.Value.Content.TryGetValue("application/json", out var jsonContent)); + Assert.NotNull(jsonContent.Schema.OneOf); + Assert.Equal(2, jsonContent.Schema.OneOf.Count); + + // text/plain should have its own schema without oneOf + Assert.True(response.Value.Content.TryGetValue("text/plain", out var textContent)); + Assert.Null(textContent.Schema.OneOf); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsThreeTypesForSameContentTypeWithOneOf() + { + // 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.OneOf); + Assert.Equal(3, content.Value.Schema.OneOf.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_SupportsOneOfForSameContentType() + { + // 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.OneOf); + Assert.Equal(2, content.Value.Schema.OneOf.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..7c227611fd70 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; @@ -250,6 +251,11 @@ public ControllerActionDescriptor CreateActionDescriptor(string methodName = nul } } + action.FilterDescriptors = action.EndpointMetadata + .OfType() + .Select((f, i) => new FilterDescriptor(f, FilterScope.Action) { Order = i }) + .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 05aaeb956848..5ac52086c44a 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 @@ -1055,6 +1055,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_SupportsOneOfForSameContentType() + { + var actionDescriptor = CreateActionDescriptor(nameof(MultiProducesController.GetOneOf), typeof(MultiProducesController)); + + await VerifyOpenApiDocument(actionDescriptor, document => + { + var operation = document.Paths["/oneof"].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.OneOf); + Assert.Equal(2, content.Value.Schema.OneOf.Count); + }); + } + [ApiController] [Produces("application/json")] public class TestController @@ -1064,6 +1097,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("/oneof")] + [ProducesResponseType(typeof(Todo), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(Error), StatusCodes.Status200OK, "application/json")] + internal IActionResult GetOneOf() => throw new NotImplementedException(); + } + private class ClassWithObjectProperty { public object Object { get; set; } From bf1ddeb28609a4a3811254eab3706ef19c3656a7 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 4 Mar 2026 17:59:56 +0100 Subject: [PATCH 05/41] rewrite filters --- .../Services/OpenApiDocumentServiceTestsBase.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 7c227611fd70..293c36ec0703 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs @@ -243,17 +243,28 @@ 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 = action.EndpointMetadata - .OfType() - .Select((f, i) => new FilterDescriptor(f, FilterScope.Action) { Order = i }) + action.FilterDescriptors = actionFilters + .Concat(controllerFilters) + .OrderBy(d => d.Order) + .ThenBy(d => d.Scope) .ToList(); action.Parameters = []; From 1de48e69705edc8406fbe8e18a14414551839388 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 4 Mar 2026 19:23:28 +0100 Subject: [PATCH 06/41] rewrite .verified --- ...ment_documentName=controllers.verified.txt | 50 +++++++++++ ...cument_documentName=responses.verified.txt | 86 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 50 +++++++++++ ...cument_documentName=responses.verified.txt | 86 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 50 +++++++++++ ...cument_documentName=responses.verified.txt | 86 +++++++++++++++++++ 6 files changed, 408 insertions(+) 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 07aaa0b07c41..8c7aa762f177 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 @@ -124,6 +124,56 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/one-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$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..23b102f8ca42 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 @@ -48,6 +48,56 @@ } } }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, "/responses/triangle": { "get": { "tags": [ @@ -182,6 +232,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 36fa03d8e378..b8528fdff942 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 @@ -124,6 +124,56 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/one-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$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..eed4782d9160 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 @@ -48,6 +48,56 @@ } } }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, "/responses/triangle": { "get": { "tags": [ @@ -182,6 +232,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 e37a837ab7d0..9007cc2167ab 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 @@ -124,6 +124,56 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/one-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$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..df00968b6f96 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 @@ -48,6 +48,56 @@ } } }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, "/responses/triangle": { "get": { "tags": [ @@ -182,6 +232,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": { From 45c060af5a7c596a4ddb411e304398b7df8caa74 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 5 Mar 2026 12:33:40 +0100 Subject: [PATCH 07/41] fix invariant verification --- ...ifyOpenApiDocumentIsInvariant.verified.txt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) 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 afe121791de1..13778d9ee92a 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 @@ -1360,6 +1360,56 @@ } } }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, "/responses/triangle": { "get": { "tags": [ @@ -1517,6 +1567,56 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/one-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } } }, "components": { From 4bd7c48f13225e5a5a728e6518f6ad0808ac77d6 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 5 Mar 2026 14:53:58 +0100 Subject: [PATCH 08/41] fix different scope overrides --- .../src/ApiResponseTypeProvider.cs | 64 +++++++++++++++++-- .../test/ApiResponseTypeProviderTest.cs | 64 ++++++++++--------- 2 files changed, 94 insertions(+), 34 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 246a6c11ce70..0395ec94df70 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -38,6 +38,7 @@ public ICollection GetApiResponseTypes(ControllerActionDescript var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); var responseMetadataAttributes = GetResponseMetadataAttributes(action); + IReadOnlyList? scopes = null; if (!HasSignificantMetadataProvider(responseMetadataAttributes) && action.Properties.TryGetValue(typeof(ApiConventionResult), out var result)) { @@ -45,6 +46,13 @@ public ICollection GetApiResponseTypes(ControllerActionDescript var apiConventionResult = (ApiConventionResult)result!; responseMetadataAttributes.AddRange(apiConventionResult.ResponseMetadataProviders); } + else + { + // When filter-based attributes are used (no conventions), extract scope info + // so that action-level attributes can properly override controller-level attributes + // for the same status code. + scopes = GetResponseMetadataScopes(action); + } var defaultErrorType = typeof(void); if (action.Properties.TryGetValue(typeof(ProducesErrorResponseTypeAttribute), out result)) @@ -53,7 +61,7 @@ public ICollection GetApiResponseTypes(ControllerActionDescript } var producesResponseMetadata = action.EndpointMetadata.OfType().ToList(); - var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType); + var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType, scopes); return apiResponseTypes; } @@ -74,11 +82,25 @@ private static List GetResponseMetadataAttributes( .ToList(); } + private static List GetResponseMetadataScopes(ControllerActionDescriptor action) + { + if (action.FilterDescriptors == null) + { + return []; + } + + return action.FilterDescriptors + .Where(fd => fd.Filter is IApiResponseMetadataProvider) + .Select(fd => fd.Scope) + .ToList(); + } + private ICollection GetApiResponseTypes( IReadOnlyList responseMetadataAttributes, IReadOnlyList producesResponseMetadata, Type? type, - Type defaultErrorType) + Type defaultErrorType, + IReadOnlyList? scopes = null) { var contentTypes = new MediaTypeCollection(); var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); @@ -98,7 +120,8 @@ private ICollection GetApiResponseTypes( defaultErrorType, contentTypes, out var _, - responseTypeMetadataProviders); + responseTypeMetadataProviders, + scopes: scopes); var responseProviderStatusCodes = responseTypesFromProvider.Values .Select(responseType => responseType.StatusCode) @@ -162,18 +185,26 @@ internal static Dictionary ReadResponseMetadata( MediaTypeCollection contentTypes, out bool errorSetByDefault, IEnumerable? responseTypeMetadataProviders = null, - IModelMetadataProvider? modelMetadataProvider = null) + IModelMetadataProvider? modelMetadataProvider = null, + IReadOnlyList? scopes = null) { errorSetByDefault = false; var results = new Dictionary(); + // When scope info is available, track the scope that added each entry so that + // higher-scope entries (action) can override lower-scope entries (controller) + // for the same status code. + Dictionary? entryScopes = scopes is not null ? new() : null; // 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. if (responseMetadataAttributes != null) { - foreach (var metadataAttribute in responseMetadataAttributes) + for (var i = 0; i < responseMetadataAttributes.Count; i++) { + var metadataAttribute = responseMetadataAttributes[i]; + var scope = scopes is not null && i < scopes.Count ? scopes[i] : 0; + // 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 @@ -239,7 +270,30 @@ internal static Dictionary ReadResponseMetadata( if (apiResponseType.Type != null) { var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, keyContentType); + + // When scope info is available, remove entries from lower scopes that define + // the same status code. This preserves the long-standing behavior where + // action-level attributes override controller-level attributes, while still + // allowing multiple entries within the same scope to coexist. + if (entryScopes is not null) + { + foreach (var existingKey in results.Keys.ToList()) + { + if (existingKey.StatusCode == apiResponseType.StatusCode && + entryScopes.TryGetValue(existingKey, out var existingScope) && + existingScope < scope) + { + results.Remove(existingKey); + entryScopes.Remove(existingKey); + } + } + } + results[key] = apiResponseType; + if (entryScopes is not null) + { + entryScopes[key] = scope; + } } } } diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index cdc8314e4783..e760821c47e7 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -97,35 +97,41 @@ public void GetApiResponseTypes_CombinesFilters() var result = provider.GetApiResponseTypes(actionDescriptor); // Assert - Assert.Equal(5, result.Count); - - Assert.Contains(result, responseType => - responseType.StatusCode == 201 && - responseType.Type == typeof(object) && - responseType.ApiResponseFormats.Count == 1 && - responseType.ApiResponseFormats[0].MediaType == "application/json"); - - Assert.Contains(result, responseType => - responseType.StatusCode == 201 && - responseType.Type == typeof(BaseModel) && - responseType.ApiResponseFormats.Count == 1 && - responseType.ApiResponseFormats[0].MediaType == "application/json"); - - Assert.Contains(result, responseType => - responseType.StatusCode == 400 && - responseType.Type == typeof(ProblemDetails) && - responseType.ApiResponseFormats.Count == 1 && - responseType.ApiResponseFormats[0].MediaType == "application/json"); - - Assert.Contains(result, responseType => - responseType.StatusCode == 400 && - responseType.Type == typeof(void) && - responseType.ApiResponseFormats.Count == 0); - - Assert.Contains(result, responseType => - responseType.StatusCode == 404 && - responseType.Type == typeof(void) && - responseType.ApiResponseFormats.Count == 0); + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); } [Fact] From 2dc09bd547b697b2a4c8c03e404ef4698ea46307 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 13:24:22 +0100 Subject: [PATCH 09/41] wip --- .../ApiResponseMetadataProviderWithScope.cs | 10 ++ .../src/ApiResponseTypeProvider.cs | 146 ++++++++++-------- .../test/ApiResponseTypeProviderTest.cs | 14 ++ 3 files changed, 104 insertions(+), 66 deletions(-) create mode 100644 src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs new file mode 100644 index 000000000000..0476d6049e1f --- /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 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 0395ec94df70..39011d733328 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -13,7 +13,9 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer; internal sealed class ApiResponseTypeProvider { - internal readonly record struct ResponseKey(int StatusCode, Type? DeclaredType, string? ContentType); + // 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; @@ -34,24 +36,18 @@ 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); - IReadOnlyList? scopes = null; if (!HasSignificantMetadataProvider(responseMetadataAttributes) && action.Properties.TryGetValue(typeof(ApiConventionResult), out var result)) { // Action does not have any conventions. Use conventions on it if present. var apiConventionResult = (ApiConventionResult)result!; - responseMetadataAttributes.AddRange(apiConventionResult.ResponseMetadataProviders); - } - else - { - // When filter-based attributes are used (no conventions), extract scope info - // so that action-level attributes can properly override controller-level attributes - // for the same status code. - scopes = GetResponseMetadataScopes(action); + + // 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: 100)); + responseMetadataAttributes.AddRange(apiConventionedAttributes); } var defaultErrorType = typeof(void); @@ -61,46 +57,32 @@ public ICollection GetApiResponseTypes(ControllerActionDescript } var producesResponseMetadata = action.EndpointMetadata.OfType().ToList(); - var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType, scopes); + 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 // while searching for a filter that implements IApiResponseMetadataProvider. // // The workaround for that is to implement the metadata interface on the IFilterFactory. - return action.FilterDescriptors - .Select(fd => fd.Filter) - .OfType() - .ToList(); - } - - private static List GetResponseMetadataScopes(ControllerActionDescriptor action) - { - if (action.FilterDescriptors == null) - { - return []; - } - return action.FilterDescriptors .Where(fd => fd.Filter is IApiResponseMetadataProvider) - .Select(fd => fd.Scope) + .Select(fd => new ApiResponseMetadataProviderWithScope((IApiResponseMetadataProvider)fd.Filter, fd.Scope)) .ToList(); } private ICollection GetApiResponseTypes( - IReadOnlyList responseMetadataAttributes, + IReadOnlyList responseMetadataAttributes, IReadOnlyList producesResponseMetadata, Type? type, - Type defaultErrorType, - IReadOnlyList? scopes = null) + Type defaultErrorType) { var contentTypes = new MediaTypeCollection(); var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); @@ -120,8 +102,7 @@ private ICollection GetApiResponseTypes( defaultErrorType, contentTypes, out var _, - responseTypeMetadataProviders, - scopes: scopes); + responseTypeMetadataProviders); var responseProviderStatusCodes = responseTypesFromProvider.Values .Select(responseType => responseType.StatusCode) @@ -147,7 +128,7 @@ private ICollection GetApiResponseTypes( // Set the default status only when no status has already been set explicitly if (responseTypes.Count == 0 && type != null) { - var defaultKey = new ResponseKey(StatusCodes.Status200OK, type, null); + var defaultKey = new ResponseKey(StatusCodes.Status200OK, type); responseTypes.Add(defaultKey, new ApiResponseType { StatusCode = StatusCodes.Status200OK, @@ -177,7 +158,7 @@ private ICollection GetApiResponseTypes( .ToList(); } - // Shared with EndpointMetadataApiDescriptionProvider + // Shared with EndpointMetadataApiDescriptionProvider for Minimal API internal static Dictionary ReadResponseMetadata( IReadOnlyList responseMetadataAttributes, Type? type, @@ -185,25 +166,38 @@ internal static Dictionary ReadResponseMetadata( MediaTypeCollection contentTypes, out bool errorSetByDefault, IEnumerable? responseTypeMetadataProviders = null, - IModelMetadataProvider? modelMetadataProvider = null, - IReadOnlyList? scopes = null) + IModelMetadataProvider? modelMetadataProvider = null) + { + // Minimal API does not have scopes, it is prioritizing some of responses based on the order they are added (if same statusCode&Content-Type) + var responseMetadataAttributesWithScope = responseMetadataAttributes + .Select((provider, index) => new ApiResponseMetadataProviderWithScope(provider, index)) + .ToList(); + + return ReadResponseMetadata(responseMetadataAttributesWithScope, type, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider); + } + + internal static Dictionary ReadResponseMetadata( + IReadOnlyList responseMetadataAttributes, + Type? type, + Type? defaultErrorType, + MediaTypeCollection contentTypes, + out bool errorSetByDefault, + IEnumerable? responseTypeMetadataProviders = null, + IModelMetadataProvider? modelMetadataProvider = null) { errorSetByDefault = false; var results = new Dictionary(); - // When scope info is available, track the scope that added each entry so that - // higher-scope entries (action) can override lower-scope entries (controller) - // for the same status code. - Dictionary? entryScopes = scopes is not null ? new() : null; + var statusCodeScopes = new Dictionary(); // Get the content type that the action explicitly set to support. // Walk through all 'filter' attributes in order, and allow each one to see or override // the results of the previous ones. This is similar to the execution path for content-negotiation. if (responseMetadataAttributes != null) { - for (var i = 0; i < responseMetadataAttributes.Count; i++) + foreach (var metadataAttributeWithScope in responseMetadataAttributes.OrderByDescending(attr => attr.Scope)) { - var metadataAttribute = responseMetadataAttributes[i]; - var scope = scopes is not null && i < scopes.Count ? scopes[i] : 0; + var metadataAttribute = metadataAttributeWithScope.Provider; + var attributeScope = metadataAttributeWithScope.Scope; // All ProducesXAttributes, except for ProducesResponseTypeAttribute do // not allow multiple instances on the same method/class/etc. For those @@ -258,41 +252,42 @@ internal static Dictionary ReadResponseMetadata( // action/controller/etc. In that scenario, instead of picking the most-specific // set of content types (like we do with the Produces attribute above) we process // the content types for each attribute independently. - string? keyContentType = null; if (metadataAttribute is ProducesResponseTypeAttribute) { var attributeContentTypes = new MediaTypeCollection(); metadataAttribute.SetContentTypes(attributeContentTypes); CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider); - keyContentType = attributeContentTypes.FirstOrDefault(); } if (apiResponseType.Type != null) { - var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, keyContentType); + var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type); - // When scope info is available, remove entries from lower scopes that define - // the same status code. This preserves the long-standing behavior where - // action-level attributes override controller-level attributes, while still - // allowing multiple entries within the same scope to coexist. - if (entryScopes is not null) + // make sure we dont keep the lesser-scope entry for same status code + if (statusCodeScopes.TryGetValue(apiResponseType.StatusCode, out var existingScope)) { - foreach (var existingKey in results.Keys.ToList()) + if (attributeScope > existingScope) + { + statusCodeScopes[apiResponseType.StatusCode] = attributeScope; + results[key] = apiResponseType; + } + else if (attributeScope == existingScope) // same statuscode, and same scope. { - if (existingKey.StatusCode == apiResponseType.StatusCode && - entryScopes.TryGetValue(existingKey, out var existingScope) && - existingScope < scope) + if (results.TryGetValue(key, out var existingEntry)) // also same type -> merge the content-types + { + MergeApiResponseFormats(existingEntry, apiResponseType); + } + else // different type { - results.Remove(existingKey); - entryScopes.Remove(existingKey); + results[key] = apiResponseType; } } } - - results[key] = apiResponseType; - if (entryScopes is not null) + else { - entryScopes[key] = scope; + // add new entry -> first statusCode per scope + statusCodeScopes[apiResponseType.StatusCode] = attributeScope; + results[key] = apiResponseType; } } } @@ -368,8 +363,16 @@ internal static Dictionary ReadResponseMetadata( } } - var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, metadata.ContentTypes?.FirstOrDefault()); - results[key] = apiResponseType; + var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type); + if (results.TryGetValue(key, out var existingEntry)) + { + // Same key: merge formats + MergeApiResponseFormats(existingEntry, apiResponseType); + } + else + { + results[key] = apiResponseType; + } } } @@ -492,18 +495,29 @@ internal static void CalculateResponseFormatForType(ApiResponseType apiResponse, return declaredReturnType; } + private static void MergeApiResponseFormats(ApiResponseType existing, ApiResponseType newEntry) + { + foreach (var format in newEntry.ApiResponseFormats) + { + if (!existing.ApiResponseFormats.Any(f => f.MediaType == format.MediaType)) + { + existing.ApiResponseFormats.Add(format); + } + } + } + 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/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index e760821c47e7..7a575f67c779 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -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, @@ -99,6 +110,7 @@ public void GetApiResponseTypes_CombinesFilters() // Assert Assert.Collection( result.OrderBy(r => r.StatusCode), + // 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); From c49578c6196da3ce6ececbc69845b46ec53ff42c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 18:58:12 +0100 Subject: [PATCH 10/41] add description to mergine of formats --- .../src/ApiResponseTypeProvider.cs | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 39011d733328..eb7f4dcc0911 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -168,9 +168,12 @@ internal static Dictionary ReadResponseMetadata( IEnumerable? responseTypeMetadataProviders = null, IModelMetadataProvider? modelMetadataProvider = null) { - // Minimal API does not have scopes, it is prioritizing some of responses based on the order they are added (if same statusCode&Content-Type) + // Minimal API does not have scopes — all metadata lives at the same level. + // Using the same scope (0) for all entries ensures that entries with the same + // status code and type are merged (e.g., different content types) rather than + // one overriding the other. var responseMetadataAttributesWithScope = responseMetadataAttributes - .Select((provider, index) => new ApiResponseMetadataProviderWithScope(provider, index)) + .Select(provider => new ApiResponseMetadataProviderWithScope(provider, scope: 0)) .ToList(); return ReadResponseMetadata(responseMetadataAttributesWithScope, type, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider); @@ -188,10 +191,12 @@ internal static Dictionary ReadResponseMetadata( errorSetByDefault = false; 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 metadataAttributeWithScope in responseMetadataAttributes.OrderByDescending(attr => attr.Scope)) @@ -199,29 +204,28 @@ internal static Dictionary ReadResponseMetadata( var metadataAttribute = metadataAttributeWithScope.Provider; var attributeScope = metadataAttributeWithScope.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) + // 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)) @@ -247,7 +251,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 @@ -263,30 +267,31 @@ internal static Dictionary ReadResponseMetadata( { var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type); - // make sure we dont keep the lesser-scope entry for same status code - if (statusCodeScopes.TryGetValue(apiResponseType.StatusCode, out var existingScope)) + if (statusCodeScopes.TryGetValue(statusCode, out var existingScope)) { if (attributeScope > existingScope) { - statusCodeScopes[apiResponseType.StatusCode] = attributeScope; + statusCodeScopes[statusCode] = attributeScope; results[key] = apiResponseType; } - else if (attributeScope == existingScope) // same statuscode, and same scope. + else if (attributeScope == existingScope) { - if (results.TryGetValue(key, out var existingEntry)) // also same type -> merge the content-types + // Same scope, same key: merge content types + if (results.TryGetValue(key, out var existingEntry)) { MergeApiResponseFormats(existingEntry, apiResponseType); } - else // different type + else { + // Same scope, different type: add alongside results[key] = apiResponseType; } } + // attributeScope < existingScope: skip, higher scope already claimed this status code } else { - // add new entry -> first statusCode per scope - statusCodeScopes[apiResponseType.StatusCode] = attributeScope; + statusCodeScopes[statusCode] = attributeScope; results[key] = apiResponseType; } } @@ -504,6 +509,12 @@ private static void MergeApiResponseFormats(ApiResponseType existing, ApiRespons existing.ApiResponseFormats.Add(format); } } + + // rewrite description + if (newEntry.Description is not null) + { + existing.Description = newEntry.Description; + } } private static bool IsClientError(int statusCode) From b84fec5c9e34ee1e8cc79f038e9c0cc3092e06b5 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 19:39:00 +0100 Subject: [PATCH 11/41] renaming --- .../src/ApiResponseTypeProvider.cs | 43 +++++++++---------- .../EndpointMetadataApiDescriptionProvider.cs | 4 +- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index eb7f4dcc0911..6dfeb17aa8fe 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -87,16 +87,17 @@ private ICollection GetApiResponseTypes( 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, responseTypeMetadataProviders, _modelMetadataProvider); - // Read response metadata from providers and - // overwrite responseTypes from the metadata based - // on the status code and content type - var responseTypesFromProvider = ReadResponseMetadata( + // Read response types from filter attributes (IApiResponseMetadataProvider), + // e.g. [ProducesResponseType], [Produces], and conventions. + var attributeResponseTypes = ReadAttributeResponseMetadata( responseMetadataAttributes, type, defaultErrorType, @@ -104,27 +105,25 @@ private ICollection GetApiResponseTypes( out var _, responseTypeMetadataProviders); - var responseProviderStatusCodes = responseTypesFromProvider.Values - .Select(responseType => responseType.StatusCode) - .ToHashSet(); - - // Preserve existing source precedence: when metadata providers/attributes define a given status code, - // entries for that status code discovered from endpoint metadata are removed before merge. - // This keeps provider metadata authoritative per status code while still allowing multiple provider - // entries for the same status code when their keys differ (type/content-type). - foreach (var existingResponseType in responseTypes.Keys.ToList()) + // Attribute metadata takes precedence: for any status code defined by attributes, + // remove all endpoint entries for that status code before merging. + var attributeStatusCodes = attributeResponseTypes.Values.Select(r => r.StatusCode).ToHashSet(); + foreach (var key in endpointResponseTypes.Keys.ToList()) { - if (responseProviderStatusCodes.Contains(existingResponseType.StatusCode)) + if (attributeStatusCodes.Contains(key.StatusCode)) { - responseTypes.Remove(existingResponseType); + endpointResponseTypes.Remove(key); } } - foreach (var responseType in responseTypesFromProvider) + // Merge Attribute metadata with Endpoint metadata + foreach (var entry in attributeResponseTypes) { - responseTypes[responseType.Key] = responseType.Value; + endpointResponseTypes[entry.Key] = entry.Value; } + var responseTypes = endpointResponseTypes; + // Set the default status only when no status has already been set explicitly if (responseTypes.Count == 0 && type != null) { @@ -159,7 +158,7 @@ private ICollection GetApiResponseTypes( } // Shared with EndpointMetadataApiDescriptionProvider for Minimal API - internal static Dictionary ReadResponseMetadata( + internal static Dictionary ReadAttributeResponseMetadata( IReadOnlyList responseMetadataAttributes, Type? type, Type? defaultErrorType, @@ -176,10 +175,10 @@ internal static Dictionary ReadResponseMetadata( .Select(provider => new ApiResponseMetadataProviderWithScope(provider, scope: 0)) .ToList(); - return ReadResponseMetadata(responseMetadataAttributesWithScope, type, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider); + return ReadAttributeResponseMetadata(responseMetadataAttributesWithScope, type, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider); } - internal static Dictionary ReadResponseMetadata( + internal static Dictionary ReadAttributeResponseMetadata( IReadOnlyList responseMetadataAttributes, Type? type, Type? defaultErrorType, @@ -301,7 +300,7 @@ and not ProducesDefaultResponseTypeAttribute return results; } - internal static Dictionary ReadResponseMetadata( + internal static Dictionary ReadEndpointResponseMetadata( IReadOnlyList responseMetadata, Type? type, IEnumerable? responseTypeMetadataProviders = null, diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 4e2a8a0d2650..e31db96c6058 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -340,9 +340,9 @@ 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. From 1a9b0913f5e8990a56d49c838ba328f3b3fa1346 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 19:41:25 +0100 Subject: [PATCH 12/41] simplify --- .../src/ApiResponseTypeProvider.cs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 6dfeb17aa8fe..a34974c24485 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -106,23 +106,12 @@ private ICollection GetApiResponseTypes( responseTypeMetadataProviders); // Attribute metadata takes precedence: for any status code defined by attributes, - // remove all endpoint entries for that status code before merging. + // all endpoint entries for that status code are replaced by the attribute entries. var attributeStatusCodes = attributeResponseTypes.Values.Select(r => r.StatusCode).ToHashSet(); - foreach (var key in endpointResponseTypes.Keys.ToList()) - { - if (attributeStatusCodes.Contains(key.StatusCode)) - { - endpointResponseTypes.Remove(key); - } - } - - // Merge Attribute metadata with Endpoint metadata - foreach (var entry in attributeResponseTypes) - { - endpointResponseTypes[entry.Key] = entry.Value; - } - - var responseTypes = endpointResponseTypes; + var responseTypes = endpointResponseTypes + .Where(kvp => !attributeStatusCodes.Contains(kvp.Key.StatusCode)) + .Concat(attributeResponseTypes) + .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) From 275fa44f9e5d6df7d0807e9dcde39c3281fb5aea Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 19:49:59 +0100 Subject: [PATCH 13/41] rework tests to use Assert.Collection without ordering (ensure order is persistent) --- .../test/ApiResponseTypeProviderTest.cs | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index 7a575f67c779..27d392778f16 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); @@ -109,7 +109,7 @@ public void GetApiResponseTypes_CombinesFilters() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, // BaseModel; 201 => scope=Action responseType => { @@ -170,7 +170,7 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -227,7 +227,7 @@ public void GetApiResponseTypes_ReturnsDescriptionFromProducesResponseType() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -281,7 +281,7 @@ public void GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatch() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -320,7 +320,7 @@ public void GetApiResponseTypes_ReturnsDefaultProblemResponse() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.True(responseType.IsDefaultResponse); @@ -376,7 +376,7 @@ public void GetApiResponseTypes_ReturnsValuesFromProducesResponseType_IfApiConve // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(201, responseType.StatusCode); @@ -419,7 +419,7 @@ public void GetApiResponseTypes_UsesErrorType_ForClientErrors() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -471,7 +471,7 @@ public void GetApiResponseTypes_UsesErrorType_ForDefaultResponse() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(errorType, responseType.Type); @@ -514,7 +514,7 @@ public void GetApiResponseTypes_DoesNotUseErrorType_IfSpecified() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(typeof(DivideByZeroException), responseType.Type); @@ -565,7 +565,7 @@ public void GetApiResponseTypes_DoesNotUseErrorType_ForNonClientErrors() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(201, responseType.StatusCode); @@ -611,7 +611,7 @@ public void GetApiResponseTypes_AllowsUsingVoid() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.True(responseType.IsDefaultResponse); @@ -657,7 +657,7 @@ public void GetApiResponseTypes_CombinesProducesAttributeAndConventions() // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.True(responseType.IsDefaultResponse); @@ -707,7 +707,7 @@ public void GetApiResponseTypes_DoesNotCombineProducesAttributeThatSpecifiesType // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -737,7 +737,7 @@ public void GetApiResponseTypes_DoesNotCombineProducesResponseTypeAttributeThatS // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -763,7 +763,7 @@ public void GetApiResponseTypes_UsesContentTypeWithoutWildCard_WhenNoFormatterSu // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -796,7 +796,7 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode), + result, responseType => { Assert.Equal(typeof(BaseModel), responseType.Type); @@ -832,9 +832,18 @@ public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeWithSameSta var result = provider.GetApiResponseTypes(actionDescriptor); - Assert.Equal(2, result.Count); - Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(BaseModel)); - Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(string)); + 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] @@ -958,7 +967,7 @@ public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeWithSameSta // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + result, responseType => { Assert.Equal(200, responseType.StatusCode); @@ -1000,7 +1009,7 @@ public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeFromEndpoin // Assert Assert.Collection( - result.OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + result, responseType => { Assert.Equal(200, responseType.StatusCode); From abed03f2e1014cefb3867fb837b696cf1341d54c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 20:27:00 +0100 Subject: [PATCH 14/41] properly validate inferred types against excplitly defined ones --- .../src/ApiResponseTypeProvider.cs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index a34974c24485..e61e855372f1 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -46,7 +46,7 @@ public ICollection GetApiResponseTypes(ControllerActionDescript var apiConventionResult = (ApiConventionResult)result!; // scope here is the highest - those are "significant" metadata providers, so we use the highest scope - var apiConventionedAttributes = apiConventionResult.ResponseMetadataProviders.Select(x => new ApiResponseMetadataProviderWithScope(x, scope: 100)); + var apiConventionedAttributes = apiConventionResult.ResponseMetadataProviders.Select(x => new ApiResponseMetadataProviderWithScope(x, scope: int.MaxValue)); responseMetadataAttributes.AddRange(apiConventionedAttributes); } @@ -56,7 +56,10 @@ public ICollection GetApiResponseTypes(ControllerActionDescript defaultErrorType = ((ProducesErrorResponseTypeAttribute)result!).Type; } - var producesResponseMetadata = action.EndpointMetadata.OfType().ToList(); + var producesResponseMetadata = action.EndpointMetadata + .OfType() + .Where(m => m is not IApiResponseMetadataProvider) + .ToList(); var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType); return apiResponseTypes; } @@ -338,21 +341,25 @@ internal static Dictionary ReadEndpointResponseMet if (apiResponseType.Type != null) { - // If metadata explicitly specifies a different type for this status code than the inferred - // return type, drop the inferred entry for that status code. This preserves long-standing - // behavior where explicit metadata takes precedence over inference while still allowing - // multiple explicit entries for the same status code. - if (type != null && - type != typeof(void) && - apiResponseType.Type != type) + // If metadata explicitly specifies a different type for this status code than + // the inferred return type, drop the inferred entry. This ensures explicit + // metadata is authoritative over inference. The type check is required so that + // multiple entries with the same type and status code can merge their formats + // instead of each removing the previous one. + if (type != null && type != typeof(void) && apiResponseType.Type != type) { - foreach (var existingResponseKey in results.Keys.ToList()) + // for case like: + // app.MapGet("/", () => new Product()) + // .Produces(200, "json") + // .Produces(200, "xml") + // .Produces(200, "xml") + // .Produces(200); + // we want add all explicit types, merge of same status-code and type, but remote all inferred types (like .Produces(200)) + + var inferredKey = new ResponseKey(apiResponseType.StatusCode, type); + if (results.TryGetValue(inferredKey, out _)) { - if (existingResponseKey.StatusCode == apiResponseType.StatusCode && - existingResponseKey.DeclaredType == type) - { - results.Remove(existingResponseKey); - } + results.Remove(inferredKey); } } From e99727c23c1c2fb033ea278578cfbf32b509d892 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Mar 2026 20:32:11 +0100 Subject: [PATCH 15/41] tests --- ...pointMetadataApiDescriptionProviderTest.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index b55980db4d8e..2949ee4893de 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1453,6 +1453,143 @@ public void FavorsProducesMetadataOverAttribute() }); } + [Fact] + public void CombinesTypedResultWithProducesExtensionAndAttribute_IResultReturn() + { + // Precedence in EndpointMetadataApiDescriptionProvider: + // - .Produces() (IProducesResponseTypeMetadata) wins per status code + // - [ProducesResponseType] (IApiResponseMetadataProvider) fills remaining status codes + // - TypedResults metadata is skipped (IResult + IEndpointMetadataProvider) + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + () => TypedResults.Created("https://example.com", new InferredJsonClass()), + httpMethods: ["POST"]); + + // Manually add .Produces() metadata by using builder pattern + var builder = CreateBuilder(); + builder.MapPost("/api/todos", + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + () => TypedResults.Created("https://example.com", new InferredJsonClass())) + .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); + + var result = Assert.Single(context.Results); + + // .Produces(200) wins for status 200 + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + + // TypedResults.Created adds 201 via IEndpointMetadataProvider — these are NOT skipped + // because the metadata (IProducesResponseTypeMetadata) is distinct from the IResult type itself. + // The Created result type adds metadata that gets processed. + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 201, Type: { } t } && t == typeof(InferredJsonClass)); + + // [ProducesResponseType(typeof(string), 404)] fills in status 404 + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 404, Type: { } t } && t == typeof(string)); + } + + [Fact] + public void CombinesTypedResultWithProducesExtensionAndAttribute_SameStatusCode_ProducesWins() + { + // When .Produces() and [ProducesResponseType] both declare the same status code, + // .Produces() (IProducesResponseTypeMetadata) takes precedence. + var builder = CreateBuilder(); + builder.MapPost("/api/todos", + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + () => TypedResults.Ok(new InferredJsonClass())) + .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); + + var result = Assert.Single(context.Results); + + // .Produces(200) wins over [ProducesResponseType(typeof(string), 200)] + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + + // The attribute's string type for 200 should NOT appear + Assert.DoesNotContain(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); + } + + [Fact] + public void CombinesProducesExtensionAndAttribute_PocoReturn() + { + // Handler returns a POCO (not IResult), so the return type is inferred as InferredJsonClass. + // Three metadata sources: + // 1. Return type inference → 200/InferredJsonClass (default, only if no metadata covers it) + // 2. .Produces(201) → ReadEndpointResponseMetadata + // 3. [ProducesResponseType(typeof(string), 404)] → ReadAttributeResponseMetadata + var builder = CreateBuilder(); + builder.MapGet("/api/todos", + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + () => new InferredJsonClass()) + .Produces(StatusCodes.Status201Created); + var context = new ApiDescriptionProviderContext(Array.Empty()); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // Inferred return type InferredJsonClass gets a default 200 response + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + + // .Produces(201) from extension method + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 201, Type: { } t } && t == typeof(TimeSpan)); + + // [ProducesResponseType(typeof(string), 404)] from attribute + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 404, Type: { } t } && t == typeof(string)); + } + + [Fact] + public void CombinesProducesExtensionAndAttribute_PocoReturn_SameStatusCode_ProducesWins() + { + // Handler returns a POCO. Both .Produces() and [ProducesResponseType] declare status 200. + // .Produces() should win for that status code. + var builder = CreateBuilder(); + builder.MapGet("/api/todos", + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + () => new InferredJsonClass()) + .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); + + var result = Assert.Single(context.Results); + + // .Produces(200) wins over [ProducesResponseType(typeof(string), 200)] + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + + // The attribute's string type for 200 should NOT appear + Assert.DoesNotContain(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); + + // The inferred InferredJsonClass for 200 should also NOT appear (Produces claimed 200) + Assert.DoesNotContain(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + } + [Fact] public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() { From cf570db01558c33469caafc59d3e817f35b81bff Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 9 Mar 2026 13:57:47 +0100 Subject: [PATCH 16/41] elaborate on inferred types --- .../src/ApiResponseTypeProvider.cs | 60 ++++++++++++------- .../EndpointMetadataApiDescriptionProvider.cs | 14 ++++- ...pointMetadataApiDescriptionProviderTest.cs | 25 ++++++-- 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index e61e855372f1..fa3b967020a8 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -294,7 +294,7 @@ and not ProducesDefaultResponseTypeAttribute internal static Dictionary ReadEndpointResponseMetadata( IReadOnlyList responseMetadata, - Type? type, + Type? inferredType, IEnumerable? responseTypeMetadataProviders = null, IModelMetadataProvider? modelMetadataProvider = null) { @@ -320,11 +320,11 @@ internal static Dictionary ReadEndpointResponseMet 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; } } @@ -341,25 +341,45 @@ internal static Dictionary ReadEndpointResponseMet if (apiResponseType.Type != null) { - // If metadata explicitly specifies a different type for this status code than - // the inferred return type, drop the inferred entry. This ensures explicit - // metadata is authoritative over inference. The type check is required so that - // multiple entries with the same type and status code can merge their formats - // instead of each removing the previous one. - if (type != null && type != typeof(void) && apiResponseType.Type != type) + // ── Controller example ────────────────────────────────────────────────── + // + // For controllers, after our .Where(m => m is not IApiResponseMetadataProvider) filter, + // responseMetadata is typically empty (attributes are handled by ReadAttributeResponseMetadata). + // So this removal logic rarely fires for controllers. + // + // ── Minimal API example (POCO return, inferredType != void) ───────────────────── + // + // app.MapGet("/", () => new Product()) // inferredType = typeof(Product) + // .Produces(200) // metadata: (200, null) → inferred as Product + // .Produces(200, "text/xml"); // metadata: (200, Customer) + // + // On 2nd iteration will remove (200, Product) because DeclaredType == inferredType (Product) + // results = { (200, Customer) } + // + // ── Minimal API example (IResult return, type == void) ────────────────── + // + // app.MapPost("/", () => TypedResults.Ok(new Product())) // type = void (IResult) + // .Produces(200); + // + // type = void → `type != typeof(void)` is false → removal is SKIPPED. + // Both TypedResults' (200, Product) and .Produces(200) coexist. + // This is expected: we cannot distinguish framework-inferred metadata from + // user-explicit metadata when both are IProducesResponseTypeMetadata. + // + // ── Minimal API example (POCO return, same type merges) ───────────────── + // + // app.MapGet("/", () => new Product()) // type = typeof(Product) + // .Produces(200, "app/json") // metadata: (200, Product) + // .Produces(200, "app/xml"); // metadata: (200, Product) + // + // For same statusCode+types will merge the ApiResponseType into results = { (200, Product) with json+xml } + // + if (inferredType != null && inferredType != typeof(void) && apiResponseType.Type != inferredType) { - // for case like: - // app.MapGet("/", () => new Product()) - // .Produces(200, "json") - // .Produces(200, "xml") - // .Produces(200, "xml") - // .Produces(200); - // we want add all explicit types, merge of same status-code and type, but remote all inferred types (like .Produces(200)) - - var inferredKey = new ResponseKey(apiResponseType.StatusCode, type); - if (results.TryGetValue(inferredKey, out _)) + var inferredTypeKey = new ResponseKey(apiResponseType.StatusCode, inferredType); + if (results.TryGetValue(inferredTypeKey, out _)) { - results.Remove(inferredKey); + results.Remove(inferredTypeKey); } } diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index e31db96c6058..0e157567bb94 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -345,7 +345,19 @@ private static void AddSupportedResponseTypes( var producesResponseMetadataTypes = ApiResponseTypeProvider.ReadEndpointResponseMetadata(producesResponseMetadata, responseType); // We favor types added via the extension methods (which implements IProducesResponseTypeMetadata) - // over those that are added via attributes. + // 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))); diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 2949ee4893de..2f3ae3270a4e 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1521,16 +1521,31 @@ public void CombinesTypedResultWithProducesExtensionAndAttribute_SameStatusCode_ // The attribute's string type for 200 should NOT appear Assert.DoesNotContain(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); + + // TypedResults.Ok() added IProducesResponseTypeMetadata(200, InferredJsonClass) + // to endpoint metadata. Both TypedResults and .Produces() are IProducesResponseTypeMetadata + // entries — there's currently no way to distinguish "framework-inferred" from "user-explicit" + // within ReadEndpointResponseMetadata, so both coexist for the same status code. + // This matches the new multi-produces behavior where different types for the same status code + // are preserved (e.g., .Produces(200, "json").Produces(200, "html")). + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); } [Fact] public void CombinesProducesExtensionAndAttribute_PocoReturn() { - // Handler returns a POCO (not IResult), so the return type is inferred as InferredJsonClass. + // Handler returns a POCO (not IResult), so responseType = typeof(InferredJsonClass). // Three metadata sources: - // 1. Return type inference → 200/InferredJsonClass (default, only if no metadata covers it) - // 2. .Produces(201) → ReadEndpointResponseMetadata - // 3. [ProducesResponseType(typeof(string), 404)] → ReadAttributeResponseMetadata + // 1. RequestDelegateFactory adds ProducesResponseTypeMetadata(200, InferredJsonClass, "application/json") + // to endpoint metadata for POCO-returning handlers (see RequestDelegateFactory.cs line ~1060). + // This is IProducesResponseTypeMetadata → goes through ReadEndpointResponseMetadata. + // 2. .Produces(201) → ReadEndpointResponseMetadata → {(201, TimeSpan)} + // 3. [ProducesResponseType(typeof(string), 404)] → ReadAttributeResponseMetadata → {(404, string)} + // + // All three survive: RDF-added (200, InferredJsonClass), extension (201, TimeSpan), + // and attribute (404, string). The 200 entry is NOT from the default fallback else branch — + // it's from RDF-added IProducesResponseTypeMetadata in the endpoint metadata. var builder = CreateBuilder(); builder.MapGet("/api/todos", [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] @@ -1545,7 +1560,7 @@ public void CombinesProducesExtensionAndAttribute_PocoReturn() var result = Assert.Single(context.Results); - // Inferred return type InferredJsonClass gets a default 200 response + // RDF-added ProducesResponseTypeMetadata(200, InferredJsonClass) from endpoint building Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); From 08720271e75c0b93e4916aad6f8e4c728eeba69a Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 11:42:36 +0100 Subject: [PATCH 17/41] simplify --- .../src/ApiResponseTypeProvider.cs | 93 ++++++------------- ...pointMetadataApiDescriptionProviderTest.cs | 79 ++++++++++++++-- 2 files changed, 102 insertions(+), 70 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index fa3b967020a8..c74bd1da9dae 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -84,7 +84,7 @@ private static List GetResponseMetadataAtt private ICollection GetApiResponseTypes( IReadOnlyList responseMetadataAttributes, IReadOnlyList producesResponseMetadata, - Type? type, + Type? declaredReturnType, Type defaultErrorType) { var contentTypes = new MediaTypeCollection(); @@ -94,15 +94,15 @@ private ICollection GetApiResponseTypes( // e.g. from TypedResults or .Produces() extension methods. var endpointResponseTypes = ReadEndpointResponseMetadata( producesResponseMetadata, - type, + declaredReturnType, responseTypeMetadataProviders, _modelMetadataProvider); // Read response types from filter attributes (IApiResponseMetadataProvider), // e.g. [ProducesResponseType], [Produces], and conventions. - var attributeResponseTypes = ReadAttributeResponseMetadata( + var filterAttributeResponseTypes = ReadFilterAttributeResponseMetadata( responseMetadataAttributes, - type, + declaredReturnType, defaultErrorType, contentTypes, out var _, @@ -110,20 +110,20 @@ private ICollection GetApiResponseTypes( // 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 = attributeResponseTypes.Values.Select(r => r.StatusCode).ToHashSet(); + var attributeStatusCodes = filterAttributeResponseTypes.Values.Select(r => r.StatusCode).ToHashSet(); var responseTypes = endpointResponseTypes .Where(kvp => !attributeStatusCodes.Contains(kvp.Key.StatusCode)) - .Concat(attributeResponseTypes) + .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) { - var defaultKey = new ResponseKey(StatusCodes.Status200OK, type); + var defaultKey = new ResponseKey(StatusCodes.Status200OK, declaredReturnType); responseTypes.Add(defaultKey, new ApiResponseType { StatusCode = StatusCodes.Status200OK, - Type = type, + Type = declaredReturnType, }); } @@ -149,30 +149,34 @@ private ICollection GetApiResponseTypes( .ToList(); } - // Shared with EndpointMetadataApiDescriptionProvider for Minimal API + // 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 API does not have scopes — all metadata lives at the same level. - // Using the same scope (0) for all entries ensures that entries with the same - // status code and type are merged (e.g., different content types) rather than - // one overriding the other. + // 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 ReadAttributeResponseMetadata(responseMetadataAttributesWithScope, type, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider); + return ReadFilterAttributeResponseMetadata(responseMetadataAttributesWithScope, declaredReturnType, defaultErrorType, contentTypes, out errorSetByDefault, responseTypeMetadataProviders, modelMetadataProvider); } - internal static Dictionary ReadAttributeResponseMetadata( + /// + /// 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? type, + Type? declaredReturnType, Type? defaultErrorType, MediaTypeCollection contentTypes, out bool errorSetByDefault, @@ -221,13 +225,13 @@ and not ProducesDefaultResponseTypeAttribute 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)) { @@ -341,56 +345,19 @@ internal static Dictionary ReadEndpointResponseMet if (apiResponseType.Type != null) { - // ── Controller example ────────────────────────────────────────────────── - // - // For controllers, after our .Where(m => m is not IApiResponseMetadataProvider) filter, - // responseMetadata is typically empty (attributes are handled by ReadAttributeResponseMetadata). - // So this removal logic rarely fires for controllers. - // - // ── Minimal API example (POCO return, inferredType != void) ───────────────────── - // - // app.MapGet("/", () => new Product()) // inferredType = typeof(Product) - // .Produces(200) // metadata: (200, null) → inferred as Product - // .Produces(200, "text/xml"); // metadata: (200, Customer) - // - // On 2nd iteration will remove (200, Product) because DeclaredType == inferredType (Product) - // results = { (200, Customer) } - // - // ── Minimal API example (IResult return, type == void) ────────────────── - // - // app.MapPost("/", () => TypedResults.Ok(new Product())) // type = void (IResult) - // .Produces(200); - // - // type = void → `type != typeof(void)` is false → removal is SKIPPED. - // Both TypedResults' (200, Product) and .Produces(200) coexist. - // This is expected: we cannot distinguish framework-inferred metadata from - // user-explicit metadata when both are IProducesResponseTypeMetadata. - // - // ── Minimal API example (POCO return, same type merges) ───────────────── - // - // app.MapGet("/", () => new Product()) // type = typeof(Product) - // .Produces(200, "app/json") // metadata: (200, Product) - // .Produces(200, "app/xml"); // metadata: (200, Product) - // - // For same statusCode+types will merge the ApiResponseType into results = { (200, Product) with json+xml } - // - if (inferredType != null && inferredType != typeof(void) && apiResponseType.Type != inferredType) - { - var inferredTypeKey = new ResponseKey(apiResponseType.StatusCode, inferredType); - if (results.TryGetValue(inferredTypeKey, out _)) - { - results.Remove(inferredTypeKey); - } - } - var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type); if (results.TryGetValue(key, out var existingEntry)) { - // Same key: merge formats + // Same (statusCode, type): merge content types. + // Example: .Produces(200, "json").Produces(200, "xml") + // → (200, Product) with [json, xml] MergeApiResponseFormats(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; } } diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 2f3ae3270a4e..f688f751c523 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1180,8 +1180,11 @@ public void RespectsProducesWithGroupNameExtensionMethod() // Assert var apiDescription = Assert.Single(context.Results); - var responseTypes = Assert.Single(apiDescription.SupportedResponseTypes); - Assert.Equal(typeof(InferredJsonClass), responseTypes.Type); + // RDF infers (200, string, "text/plain") from the `() => ""` return type, + // and .Produces() adds (200, InferredJsonClass, "application/json"). + // Both coexist as IProducesResponseTypeMetadata entries. + Assert.Contains(apiDescription.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); Assert.Equal(endpointGroupName, apiDescription.GroupName); } @@ -1231,8 +1234,10 @@ public void HandlesProducesWithProducesProblem() provider.OnProvidersExecuted(context); // Assert + // RDF infers (200, string, "text/plain") from `() => ""`, which coexists + // with .Produces(200) since both are IProducesResponseTypeMetadata. Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); @@ -1240,6 +1245,12 @@ public void HandlesProducesWithProducesProblem() Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); }, responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "text/plain" }, GetSortedMediaTypes(responseType)); + }, + responseType => { Assert.Equal(typeof(HttpValidationProblemDetails), responseType.Type); Assert.Equal(400, responseType.StatusCode); @@ -1281,8 +1292,10 @@ public void HandleMultipleProduces() provider.OnProvidersExecuted(context); // Assert + // RDF infers (200, string, "text/plain") from `() => ""`, which coexists + // with .Produces(200). Both are IProducesResponseTypeMetadata. Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); @@ -1290,6 +1303,12 @@ public void HandleMultipleProduces() Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); }, responseType => + { + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "text/plain" }, GetSortedMediaTypes(responseType)); + }, + responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); Assert.Equal(201, responseType.StatusCode); @@ -1443,13 +1462,23 @@ public void FavorsProducesMetadataOverAttribute() provider.OnProvidersExecuted(context); // Assert + // [ProducesResponseType(typeof(List), 200)] is an attribute (IApiResponseMetadataProvider). + // .Produces(200) and RDF-inferred (200, string) are endpoint metadata + // (IProducesResponseTypeMetadata). Endpoint metadata wins for status 200, so the attribute + // entry is dropped. Both endpoint entries coexist. Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), 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[] { "text/plain" }, GetSortedMediaTypes(responseType)); }); } @@ -1600,11 +1629,47 @@ public void CombinesProducesExtensionAndAttribute_PocoReturn_SameStatusCode_Prod Assert.DoesNotContain(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); - // The inferred InferredJsonClass for 200 should also NOT appear (Produces claimed 200) - Assert.DoesNotContain(result.SupportedResponseTypes, + // The inferred InferredJsonClass for 200 also appears (RDF-added entry coexists + // with .Produces(200) since both are IProducesResponseTypeMetadata). + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); } + [Fact] + public void ExplicitProducesMatchingInferredType_NotRemovedByOtherProduces() + { + // Regression test: explicit .Produces() calls that happen to match the inferred return + // type must NOT be removed when a different .Produces() for the same status code appears. + // + // Handler: () => new InferredJsonClass() → inferredType = InferredJsonClass + // Metadata: + // RDF adds (200, InferredJsonClass, "application/json") + // .Produces(200, "text/xml") → explicit, same type as inferred + // .Produces(200, "text/plain") → explicit, different type + // + // All three (200, InferredJsonClass) entries merge, and (200, TimeSpan) coexists. + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => new InferredJsonClass()) + .Produces(StatusCodes.Status200OK, "text/xml") + .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); + + var result = Assert.Single(context.Results); + + // (200, InferredJsonClass) must survive — it was explicitly declared via .Produces<>() + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + + // (200, TimeSpan) coexists for the same status code + Assert.Contains(result.SupportedResponseTypes, + r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + } + [Fact] public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() { From 6179e23e881b4f0de42d7f0029fe5964e7f9bef9 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 12:35:43 +0100 Subject: [PATCH 18/41] tests for controllers --- .../test/ApiResponseTypeProviderTest.cs | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index 27d392778f16..cda5f62b7dec 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -1024,6 +1024,186 @@ public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeFromEndpoin }); } + [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); + } + + public class VoidController : ControllerBase + { + public Task Delete() => Task.CompletedTask; + } + public static class SearchApiConventions { [ProducesResponseType(206)] From 613544675a917da15c00ffb4d00589e88c5ea63e Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 12:41:42 +0100 Subject: [PATCH 19/41] minimal api tests --- ...pointMetadataApiDescriptionProviderTest.cs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index f688f751c523..74c2441addb1 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1378,6 +1378,82 @@ public void HandleMultipleProducesWithSameStatusCodeAndDifferentTypesWithoutCont }); } + [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).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + 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() { From 3186309a3f6d189aacbef84c8fb0f233e191fe87 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 12:43:58 +0100 Subject: [PATCH 20/41] nit --- src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index c74bd1da9dae..fa1ba306565b 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -264,12 +264,9 @@ and not ProducesDefaultResponseTypeAttribute if (statusCodeScopes.TryGetValue(statusCode, out var existingScope)) { - if (attributeScope > existingScope) - { - statusCodeScopes[statusCode] = attributeScope; - results[key] = apiResponseType; - } - else if (attributeScope == existingScope) + // attributeScope > existingScope: cannot happend due to desc order processing + // 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)) @@ -282,7 +279,6 @@ and not ProducesDefaultResponseTypeAttribute results[key] = apiResponseType; } } - // attributeScope < existingScope: skip, higher scope already claimed this status code } else { From f28fd3ea272949e4406bb89fba87d5dcb2637b59 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 13:06:54 +0100 Subject: [PATCH 21/41] tests on Results.Ok and TypedResults.OK() --- ...pointMetadataApiDescriptionProviderTest.cs | 175 ++++++++++++++++-- 1 file changed, 158 insertions(+), 17 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 74c2441addb1..3cdef50f236c 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1576,7 +1576,7 @@ public void CombinesTypedResultWithProducesExtensionAndAttribute_IResultReturn() [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] () => TypedResults.Created("https://example.com", new InferredJsonClass())) .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext(Array.Empty()); + var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); @@ -1586,18 +1586,15 @@ public void CombinesTypedResultWithProducesExtensionAndAttribute_IResultReturn() var result = Assert.Single(context.Results); // .Produces(200) wins for status 200 - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); // TypedResults.Created adds 201 via IEndpointMetadataProvider — these are NOT skipped // because the metadata (IProducesResponseTypeMetadata) is distinct from the IResult type itself. // The Created result type adds metadata that gets processed. - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 201, Type: { } t } && t == typeof(InferredJsonClass)); + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 201, Type: { } t } && t == typeof(InferredJsonClass)); // [ProducesResponseType(typeof(string), 404)] fills in status 404 - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 404, Type: { } t } && t == typeof(string)); + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 404, Type: { } t } && t == typeof(string)); } [Fact] @@ -1610,7 +1607,7 @@ public void CombinesTypedResultWithProducesExtensionAndAttribute_SameStatusCode_ [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] () => TypedResults.Ok(new InferredJsonClass())) .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext(Array.Empty()); + var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); @@ -1620,12 +1617,10 @@ public void CombinesTypedResultWithProducesExtensionAndAttribute_SameStatusCode_ var result = Assert.Single(context.Results); // .Produces(200) wins over [ProducesResponseType(typeof(string), 200)] - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); // The attribute's string type for 200 should NOT appear - Assert.DoesNotContain(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); + Assert.DoesNotContain(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); // TypedResults.Ok() added IProducesResponseTypeMetadata(200, InferredJsonClass) // to endpoint metadata. Both TypedResults and .Produces() are IProducesResponseTypeMetadata @@ -1633,8 +1628,7 @@ public void CombinesTypedResultWithProducesExtensionAndAttribute_SameStatusCode_ // within ReadEndpointResponseMetadata, so both coexist for the same status code. // This matches the new multi-produces behavior where different types for the same status code // are preserved (e.g., .Produces(200, "json").Produces(200, "html")). - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); } [Fact] @@ -1656,7 +1650,7 @@ public void CombinesProducesExtensionAndAttribute_PocoReturn() [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] () => new InferredJsonClass()) .Produces(StatusCodes.Status201Created); - var context = new ApiDescriptionProviderContext(Array.Empty()); + var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); @@ -1688,7 +1682,7 @@ public void CombinesProducesExtensionAndAttribute_PocoReturn_SameStatusCode_Prod [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] () => new InferredJsonClass()) .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext(Array.Empty()); + var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); @@ -1728,7 +1722,7 @@ public void ExplicitProducesMatchingInferredType_NotRemovedByOtherProduces() builder.MapGet("/api/todos", () => new InferredJsonClass()) .Produces(StatusCodes.Status200OK, "text/xml") .Produces(StatusCodes.Status200OK, "text/plain"); - var context = new ApiDescriptionProviderContext(Array.Empty()); + var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); @@ -1746,6 +1740,153 @@ public void ExplicitProducesMatchingInferredType_NotRemovedByOtherProduces() r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); } + [Fact] + public void TypedResultsOk_WithProducesSameType_MergesContentTypes() + { + // TypedResults.Ok(obj) adds ProducesResponseTypeMetadata(200, T, "application/json") via IEndpointMetadataProvider. + // .Produces(200, "text/xml") adds another ProducesResponseTypeMetadata(200, T, "text/xml"). + // Same (200, T) → content types merge into a single entry. + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => TypedResults.Ok(new InferredJsonClass())) + .Produces(StatusCodes.Status200OK, "text/xml"); + var context = new ApiDescriptionProviderContext([]); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // Single (200, InferredJsonClass) with both content types merged + var responseType = Assert.Single(result.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); + } + + [Fact] + public void TypedResultsOk_WithProducesDifferentType_BothCoexist() + { + // TypedResults.Ok(obj) adds (200, T). .Produces(200) adds (200, U). + // Different types for same status code → both coexist. + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => TypedResults.Ok(new InferredJsonClass())) + .Produces(StatusCodes.Status200OK); + var context = new ApiDescriptionProviderContext([]); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // Both types coexist for status 200 + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); + } + + [Fact] + public void TypedResultsOk_NoPayload_WithProduce_BothCoexist() + { + // TypedResults.Ok() adds ProducesResponseTypeMetadata(200, null) + // coexists with .Produces(200, "application/json") + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => TypedResults.Ok()) + .Produces(StatusCodes.Status200OK); + var context = new ApiDescriptionProviderContext([]); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // .Produces(200) → (200, InferredJsonClass) + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + // TypedResults.Ok() adds metadata with null type → inferred as void → (200, void) with no formats + Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(void)); + } + + [Fact] + public void ResultsOk_NoPayload_WithProduce_OnlyProducesSurvives() + { + // Results.Ok() returns IResult — can't infer a type, no metadata added. + // Only .Produces(200) survives. + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => Results.Ok()) + .Produces(StatusCodes.Status200OK); + var context = new ApiDescriptionProviderContext([]); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // Only .Produces(200) survives + var responseType = Assert.Single(result.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + } + + [Fact] + public void ResultsOk_WithPayload_WithProduce_OnlyProducesSurvives() + { + // Results.Ok(obj) returns IResult — can't see through to the payload type. + // .Produces(200) is the only metadata source. + + var builder = CreateBuilder(); + builder.MapGet("/api/todos", () => Results.Ok(new InferredJsonClass())) + .Produces(StatusCodes.Status200OK); + var context = new ApiDescriptionProviderContext([]); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // Only .Produces(200) — Results.Ok(obj) contributes no metadata + var responseType = Assert.Single(result.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); + } + + [Fact] + public void TypedResultsCreated_WithProducesSameType_MergesContentTypes() + { + // TypedResults.Created(url, obj) + .Produces(201, "text/xml") with same type → merge + + var builder = CreateBuilder(); + builder.MapPost("/api/todos", () => TypedResults.Created("https://example.com", new InferredJsonClass())) + .Produces(StatusCodes.Status201Created, "text/xml"); + var context = new ApiDescriptionProviderContext([]); + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var result = Assert.Single(context.Results); + + // Single (201, InferredJsonClass) with merged content types + var responseType = Assert.Single(result.SupportedResponseTypes); + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(new[] { "application/json", "text/xml" }, GetSortedMediaTypes(responseType)); + } + [Fact] public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() { From cb64b6dde9e6fafbddc7740bfe57f6d811b11ecf Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 13:11:41 +0100 Subject: [PATCH 22/41] nit --- .../test/EndpointMetadataApiDescriptionProviderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 3cdef50f236c..46082f137c3c 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1884,7 +1884,7 @@ public void TypedResultsCreated_WithProducesSameType_MergesContentTypes() var responseType = Assert.Single(result.SupportedResponseTypes); Assert.Equal(201, responseType.StatusCode); Assert.Equal(typeof(InferredJsonClass), responseType.Type); - Assert.Equal(new[] { "application/json", "text/xml" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); } [Fact] From 7436ce42073c357a416a80541dd3c747e8379347 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 13:34:10 +0100 Subject: [PATCH 23/41] add explanation for filtering non-ApiResponseMetadataProviders --- .../src/ApiResponseTypeProvider.cs | 5 + ...cument_documentName=responses.received.txt | 306 ++ ...cument_documentName=responses.received.txt | 306 ++ ...cument_documentName=responses.received.txt | 306 ++ ...ifyOpenApiDocumentIsInvariant.received.txt | 2723 +++++++++++++++++ 5 files changed, 3646 insertions(+) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index fa1ba306565b..21340b1d516f 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -56,6 +56,11 @@ public ICollection GetApiResponseTypes(ControllerActionDescript defaultErrorType = ((ProducesErrorResponseTypeAttribute)result!).Type; } + // ProducesResponseTypeAttribute implements both IApiResponseMetadataProvider and + // IProducesResponseTypeMetadata. Filter attributes are already processed with scope + // support via ReadFilterAttributeResponseMetadata, so we exclude them here to + // avoid processing the same attribute twice. This leaves only "pure" endpoint metadata + // entries (e.g., from TypedResults or custom IProducesResponseTypeMetadata implementations). var producesResponseMetadata = action.EndpointMetadata .OfType() .Where(m => m is not IApiResponseMetadataProvider) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt new file mode 100644 index 000000000000..826a7038e600 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt @@ -0,0 +1,306 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Sample | responses", + "version": "1.0.0" + }, + "paths": { + "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-only-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, + "/responses/triangle": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/responses/shape": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Shape" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Shape": { + "required": [ + "$type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/ShapeTriangle" + }, + { + "$ref": "#/components/schemas/ShapeSquare" + } + ], + "discriminator": { + "propertyName": "$type", + "mapping": { + "triangle": "#/components/schemas/ShapeTriangle", + "square": "#/components/schemas/ShapeSquare" + } + } + }, + "ShapeSquare": { + "properties": { + "$type": { + "enum": [ + "square" + ], + "type": "string" + }, + "area": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "ShapeTriangle": { + "properties": { + "$type": { + "enum": [ + "triangle" + ], + "type": "string" + }, + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "Todo": { + "required": [ + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "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." + }, + "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": { + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "tags": [ + { + "name": "Sample" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt new file mode 100644 index 000000000000..44e254bdfabc --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt @@ -0,0 +1,306 @@ +{ + "openapi": "3.1.2", + "info": { + "title": "Sample | responses", + "version": "1.0.0" + }, + "paths": { + "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-only-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, + "/responses/triangle": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/responses/shape": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Shape" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Shape": { + "required": [ + "$type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/ShapeTriangle" + }, + { + "$ref": "#/components/schemas/ShapeSquare" + } + ], + "discriminator": { + "propertyName": "$type", + "mapping": { + "triangle": "#/components/schemas/ShapeTriangle", + "square": "#/components/schemas/ShapeSquare" + } + } + }, + "ShapeSquare": { + "properties": { + "$type": { + "enum": [ + "square" + ], + "type": "string" + }, + "area": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "ShapeTriangle": { + "properties": { + "$type": { + "enum": [ + "triangle" + ], + "type": "string" + }, + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "Todo": { + "required": [ + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "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." + }, + "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": { + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "tags": [ + { + "name": "Sample" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt new file mode 100644 index 000000000000..3cf92ecea1c4 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt @@ -0,0 +1,306 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "Sample | responses", + "version": "1.0.0" + }, + "paths": { + "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-only-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, + "/responses/triangle": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/responses/shape": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Shape" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Shape": { + "required": [ + "$type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/ShapeTriangle" + }, + { + "$ref": "#/components/schemas/ShapeSquare" + } + ], + "discriminator": { + "propertyName": "$type", + "mapping": { + "triangle": "#/components/schemas/ShapeTriangle", + "square": "#/components/schemas/ShapeSquare" + } + } + }, + "ShapeSquare": { + "properties": { + "$type": { + "enum": [ + "square" + ], + "type": "string" + }, + "area": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "ShapeTriangle": { + "properties": { + "$type": { + "enum": [ + "triangle" + ], + "type": "string" + }, + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "Todo": { + "required": [ + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "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." + }, + "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": { + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "tags": [ + { + "name": "Sample" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt new file mode 100644 index 000000000000..3f1f5c586add --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt @@ -0,0 +1,2723 @@ +{ + "openapi": "3.1.2", + "info": { + "title": "Sample | localized", + "description": "This is a localized OpenAPI document for français (France).", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/forms/form-file": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "resume" + ], + "type": "object", + "properties": { + "resume": { + "$ref": "#/components/schemas/IFormFile" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/forms/form-files": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "files" + ], + "type": "object", + "properties": { + "files": { + "$ref": "#/components/schemas/IFormFileCollection" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/forms/form-file-multiple": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "resume", + "files" + ], + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "resume": { + "$ref": "#/components/schemas/IFormFile" + } + } + }, + { + "type": "object", + "properties": { + "files": { + "$ref": "#/components/schemas/IFormFileCollection" + } + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/forms/form-todo": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/forms/forms-pocos-and-files": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "file" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "type": "object", + "properties": { + "file": { + "$ref": "#/components/schemas/IFormFile" + } + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/v1/array-of-guids": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "guids", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "/v1/todos": { + "post": { + "tags": [ + "Sample" + ], + "summary": "Creates a new todo item.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/v1/todos/{id}": { + "get": { + "tags": [ + "Sample" + ], + "description": "Returns a specific todo item.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/v2/users": { + "get": { + "tags": [ + "users" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Sample" + ], + "operationId": "CreateUser", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/type-with-examples": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeWithExamples" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeWithExamples" + } + } + } + } + } + } + }, + "/xml/todo": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoFomInterface" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/project": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/board": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardItem" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/project-record": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecord" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/todo-with-description": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDescription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/Xml": { + "get": { + "tags": [ + "Xml" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the person.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns the greeting.", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "Xml" + ], + "requestBody": { + "description": "The todo to insert into the database.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/schemas-by-ref/typed-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/schemas-by-ref/multiple-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/schemas-by-ref/iresult-no-produces": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/iresult-with-produces": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/schemas-by-ref/primitives": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The ID associated with the Todo item.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "The number of Todos to fetch", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/product": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + } + } + } + }, + "/schemas-by-ref/account": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Account" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Account" + } + } + } + } + } + } + }, + "/schemas-by-ref/array-of-ints": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "/schemas-by-ref/list-of-ints": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "/schemas-by-ref/ienumerable-of-ints": { + "post": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "/schemas-by-ref/dictionary-of-ints": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + } + }, + "/schemas-by-ref/frozen-dictionary-of-ints": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + } + }, + "/schemas-by-ref/shape": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Shape" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/weatherforecastbase": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherForecastBase" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/person": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/category": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/container": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContainerType" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/root": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Root" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/location": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationContainer" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/parent": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParentObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/child": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChildObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/custom-iresult": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomIResultImplementor" + } + } + } + } + } + } + }, + "/schemas-by-ref/config-with-generic-lists": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/project-response": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/subscription": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-return-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NullableResponseModel" + } + ] + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-request": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NullableRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/complex-nullable-hierarchy": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-array-elements": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableArrayModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/optional-with-default": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } + } + } + } + }, + "/responses/200-add-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, + "/responses/200-only-xml": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } + } + } + } + } + } + }, + "/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-one-of": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] + } + } + } + } + } + } + }, + "/responses/triangle": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/responses/shape": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Shape" + } + } + } + } + } + } + }, + "/getbyidandname/{id}/{name}": { + "get": { + "tags": [ + "Test" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "minLength": 5, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/gettypedresult": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } + }, + "/forms": { + "post": { + "tags": [ + "Test" + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "Title": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "IsCompleted": { + "type": "boolean" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/getcultureinvariant": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/one-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Account": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + } + }, + "AddressDto": { + "required": [ + "relatedLocation" + ], + "type": "object", + "properties": { + "relatedLocation": { + "$ref": "#/components/schemas/LocationDto" + } + } + }, + "BoardItem": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "description": "An item on the board." + }, + "Category": { + "required": [ + "name", + "parent" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/Category" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + } + } + }, + "ChildObject": { + "required": [ + "parent" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "parent": { + "$ref": "#/components/schemas/ParentObject" + } + } + }, + "CityResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NestedModel" + } + ] + }, + "requiredNested": { + "$ref": "#/components/schemas/NestedModel" + }, + "nullableListWithNullableItems": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/NestedModel" + } + } + } + }, + "ComplexType": { + "type": "object", + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "timestamp": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, + "Config": { + "type": "object", + "properties": { + "items1": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + }, + "items2": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + } + } + }, + "ConfigItem": { + "type": "object", + "properties": { + "id": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "lang": { + "type": [ + "null", + "string" + ] + }, + "words": { + "type": [ + "null", + "object" + ] + }, + "break": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "willBeGood": { + "type": [ + "null", + "string" + ] + } + } + }, + "ContainerType": { + "type": "object", + "properties": { + "seq1": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "seq2": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "CurrentWeather": { + "type": "object", + "properties": { + "temperature": { + "maximum": 100.5, + "minimum": -100.5, + "type": "number", + "format": "float", + "default": 0.1 + } + } + }, + "CustomIResultImplementor": { + "required": [ + "content" + ], + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + }, + "EnumNullableModel": { + "required": [ + "requiredEnum" + ], + "type": "object", + "properties": { + "requiredEnum": { + "$ref": "#/components/schemas/TestEnum" + }, + "nullableEnum": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + }, + "listOfNullableEnums": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + }, + "IFormFile": { + "type": "string", + "format": "binary" + }, + "IFormFileCollection": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IFormFile" + } + }, + "Item": { + "type": "object", + "properties": { + "name": { + "type": "array", + "items": { + "type": "string" + } + }, + "value": { + "type": "integer", + "format": "int32" + } + } + }, + "JsonPatchDocument": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "op", + "path", + "value" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "add", + "replace", + "test" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "value": { } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path", + "from" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "move", + "copy" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "from": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "remove" + ], + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, + "LocationContainer": { + "required": [ + "location" + ], + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationDto" + } + } + }, + "LocationDto": { + "required": [ + "address" + ], + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/AddressDto" + } + } + }, + "ModelWithDefaults": { + "type": "object", + "properties": { + "propertyWithDefault": { + "type": "string" + }, + "nullableWithNull": { + "type": [ + "null", + "string" + ] + }, + "numberWithDefault": { + "type": "integer", + "format": "int32" + }, + "boolWithDefault": { + "type": "boolean" + } + } + }, + "MvcTodo": { + "required": [ + "title", + "description", + "isCompleted" + ], + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isCompleted": { + "type": "boolean" + } + } + }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deepNested": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, + "NullableArrayModel": { + "type": "object", + "properties": { + "nullableArray": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "listWithNullableElements": { + "type": "array", + "items": { + "type": "string" + } + }, + "nullableDictionaryWithNullableValues": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "NullableRequestModel": { + "required": [ + "requiredField" + ], + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "optionalField": { + "type": [ + "null", + "string" + ] + }, + "nullableList": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "nullableDictionary": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "NullableResponseModel": { + "required": [ + "requiredProperty" + ], + "type": "object", + "properties": { + "requiredProperty": { + "type": "string" + }, + "nullableProperty": { + "type": [ + "null", + "string" + ] + }, + "nullableComplexProperty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, + "ParentObject": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChildObject" + } + } + } + }, + "Person": { + "required": [ + "discriminator" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/PersonStudent" + }, + { + "$ref": "#/components/schemas/PersonTeacher" + } + ], + "discriminator": { + "propertyName": "discriminator", + "mapping": { + "student": "#/components/schemas/PersonStudent", + "teacher": "#/components/schemas/PersonTeacher" + } + } + }, + "PersonStudent": { + "properties": { + "discriminator": { + "enum": [ + "student" + ], + "type": "string" + }, + "gpa": { + "type": "number", + "format": "double" + } + } + }, + "PersonTeacher": { + "required": [ + "subject" + ], + "properties": { + "discriminator": { + "enum": [ + "teacher" + ], + "type": "string" + }, + "subject": { + "type": "string" + } + } + }, + "Product": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + } + }, + "Project": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "description": "The project that contains Todo items." + }, + "ProjectAddressResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectBuilderResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectRecord": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the project." + }, + "description": { + "type": "string", + "description": "The description of the project." + } + }, + "description": "The project that contains Todo items." + }, + "ProjectResponse": { + "required": [ + "address", + "builder" + ], + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/ProjectAddressResponse" + }, + "builder": { + "$ref": "#/components/schemas/ProjectBuilderResponse" + } + } + }, + "RefProfile": { + "required": [ + "user" + ], + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/RefUser" + } + } + }, + "RefUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "Root": { + "type": "object", + "properties": { + "item1": { + "$ref": "#/components/schemas/Item" + }, + "item2": { + "$ref": "#/components/schemas/Item" + } + } + }, + "Shape": { + "required": [ + "$type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/ShapeTriangle" + }, + { + "$ref": "#/components/schemas/ShapeSquare" + } + ], + "discriminator": { + "propertyName": "$type", + "mapping": { + "triangle": "#/components/schemas/ShapeTriangle", + "square": "#/components/schemas/ShapeSquare" + } + } + }, + "ShapeSquare": { + "properties": { + "$type": { + "enum": [ + "square" + ], + "type": "string" + }, + "area": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "ShapeTriangle": { + "properties": { + "$type": { + "enum": [ + "triangle" + ], + "type": "string" + }, + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "Subscription": { + "required": [ + "id", + "primaryUser" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "primaryUser": { + "$ref": "#/components/schemas/RefProfile" + }, + "secondaryUser": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] + } + } + }, + "Tag": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "TestEnum": { + "type": "integer" + }, + "Todo": { + "required": [ + "id", + "title", + "completed", + "createdAt" + ], + "type": "object", + "properties": { + "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." + }, + "TodoFomInterface": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The identifier of the todo.", + "format": "int32" + }, + "name": { + "type": "string", + "description": "The name of the todo." + }, + "description": { + "type": "string", + "description": "A description of the todo." + } + }, + "description": "This is a todo item." + }, + "TodoWithDescription": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The identifier of the todo, overridden.", + "format": "int32" + }, + "name": { + "type": "string", + "description": "The name of the todo, overridden." + }, + "description": { + "type": "string", + "description": "A description of the the todo.\nAnother description of the todo." + } + } + }, + "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": { + "hypotenuse": { + "type": "number", + "format": "double" + }, + "color": { + "type": "string" + }, + "sides": { + "type": "integer", + "format": "int32" + } + } + }, + "TypeWithExamples": { + "type": "object", + "properties": { + "booleanType": { + "type": "boolean", + "example": true + }, + "integerType": { + "type": "integer", + "format": "int32", + "example": 42 + }, + "longType": { + "type": "integer", + "format": "int64", + "example": 1234567890123456789 + }, + "doubleType": { + "type": "number", + "format": "double", + "example": 3.14 + }, + "floatType": { + "type": "number", + "format": "float", + "example": 3.14 + }, + "dateTimeType": { + "type": "string", + "format": "date-time", + "example": "2022-01-01T00:00:00Z" + }, + "dateOnlyType": { + "type": "string", + "format": "date", + "example": "2022-01-01" + } + } + }, + "WeatherForecastBase": { + "required": [ + "$type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity" + }, + { + "$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries" + }, + { + "$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews" + } + ], + "discriminator": { + "propertyName": "$type", + "mapping": { + "0": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity", + "1": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries", + "2": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews" + } + } + }, + "WeatherForecastBaseWeatherForecastWithCity": { + "required": [ + "city" + ], + "properties": { + "$type": { + "enum": [ + 0 + ], + "type": "integer" + }, + "city": { + "type": "string" + } + } + }, + "WeatherForecastBaseWeatherForecastWithLocalNews": { + "required": [ + "news" + ], + "properties": { + "$type": { + "enum": [ + 2 + ], + "type": "integer" + }, + "news": { + "type": "string" + } + } + }, + "WeatherForecastBaseWeatherForecastWithTimeSeries": { + "required": [ + "summary" + ], + "properties": { + "$type": { + "enum": [ + 1 + ], + "type": "integer" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "temperatureC": { + "type": "integer", + "format": "int32" + }, + "summary": { + "type": "string" + } + } + } + } + }, + "tags": [ + { + "name": "Sample" + }, + { + "name": "users" + }, + { + "name": "Xml" + }, + { + "name": "Test" + } + ] +} \ No newline at end of file From 9b0920cbbfc2834d3d073eff3645b7a3440680ca Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 13:40:04 +0100 Subject: [PATCH 24/41] update .verified to include inferred types --- ...cument_documentName=responses.received.txt | 306 -- ...cument_documentName=responses.verified.txt | 14 +- ...cument_documentName=responses.received.txt | 306 -- ...cument_documentName=responses.verified.txt | 14 +- ...cument_documentName=responses.received.txt | 306 -- ...cument_documentName=responses.verified.txt | 14 +- ...ifyOpenApiDocumentIsInvariant.received.txt | 2723 ----------------- ...ifyOpenApiDocumentIsInvariant.verified.txt | 14 +- 8 files changed, 52 insertions(+), 3645 deletions(-) delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt deleted file mode 100644 index 826a7038e600..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt +++ /dev/null @@ -1,306 +0,0 @@ -{ - "openapi": "3.0.4", - "info": { - "title": "Sample | responses", - "version": "1.0.0" - }, - "paths": { - "/responses/200-add-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - }, - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - } - } - } - } - }, - "/responses/200-only-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoWithDueDate" - } - } - } - } - } - } - }, - "/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-one-of": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - } - } - } - } - } - }, - "/responses/triangle": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - } - } - } - }, - "/responses/shape": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Shape" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Shape": { - "required": [ - "$type" - ], - "type": "object", - "anyOf": [ - { - "$ref": "#/components/schemas/ShapeTriangle" - }, - { - "$ref": "#/components/schemas/ShapeSquare" - } - ], - "discriminator": { - "propertyName": "$type", - "mapping": { - "triangle": "#/components/schemas/ShapeTriangle", - "square": "#/components/schemas/ShapeSquare" - } - } - }, - "ShapeSquare": { - "properties": { - "$type": { - "enum": [ - "square" - ], - "type": "string" - }, - "area": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "ShapeTriangle": { - "properties": { - "$type": { - "enum": [ - "triangle" - ], - "type": "string" - }, - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "Todo": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "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." - }, - "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": { - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - } - } - }, - "tags": [ - { - "name": "Sample" - } - ] -} \ No newline at end of file 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 23b102f8ca42..826a7038e600 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 @@ -16,7 +16,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Todo" + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] } }, "text/xml": { @@ -42,6 +49,11 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } } } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt deleted file mode 100644 index 44e254bdfabc..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt +++ /dev/null @@ -1,306 +0,0 @@ -{ - "openapi": "3.1.2", - "info": { - "title": "Sample | responses", - "version": "1.0.0" - }, - "paths": { - "/responses/200-add-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - }, - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - } - } - } - } - }, - "/responses/200-only-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoWithDueDate" - } - } - } - } - } - } - }, - "/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-one-of": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - } - } - } - } - } - }, - "/responses/triangle": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - } - } - } - }, - "/responses/shape": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Shape" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Shape": { - "required": [ - "$type" - ], - "type": "object", - "anyOf": [ - { - "$ref": "#/components/schemas/ShapeTriangle" - }, - { - "$ref": "#/components/schemas/ShapeSquare" - } - ], - "discriminator": { - "propertyName": "$type", - "mapping": { - "triangle": "#/components/schemas/ShapeTriangle", - "square": "#/components/schemas/ShapeSquare" - } - } - }, - "ShapeSquare": { - "properties": { - "$type": { - "enum": [ - "square" - ], - "type": "string" - }, - "area": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "ShapeTriangle": { - "properties": { - "$type": { - "enum": [ - "triangle" - ], - "type": "string" - }, - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "Todo": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "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." - }, - "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": { - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - } - } - }, - "tags": [ - { - "name": "Sample" - } - ] -} \ No newline at end of file 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 eed4782d9160..44e254bdfabc 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 @@ -16,7 +16,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Todo" + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] } }, "text/xml": { @@ -42,6 +49,11 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } } } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt deleted file mode 100644 index 3cf92ecea1c4..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.received.txt +++ /dev/null @@ -1,306 +0,0 @@ -{ - "openapi": "3.2.0", - "info": { - "title": "Sample | responses", - "version": "1.0.0" - }, - "paths": { - "/responses/200-add-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - }, - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - } - } - } - } - }, - "/responses/200-only-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoWithDueDate" - } - } - } - } - } - } - }, - "/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-one-of": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - } - } - } - } - } - }, - "/responses/triangle": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - } - } - } - }, - "/responses/shape": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Shape" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Shape": { - "required": [ - "$type" - ], - "type": "object", - "anyOf": [ - { - "$ref": "#/components/schemas/ShapeTriangle" - }, - { - "$ref": "#/components/schemas/ShapeSquare" - } - ], - "discriminator": { - "propertyName": "$type", - "mapping": { - "triangle": "#/components/schemas/ShapeTriangle", - "square": "#/components/schemas/ShapeSquare" - } - } - }, - "ShapeSquare": { - "properties": { - "$type": { - "enum": [ - "square" - ], - "type": "string" - }, - "area": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "ShapeTriangle": { - "properties": { - "$type": { - "enum": [ - "triangle" - ], - "type": "string" - }, - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "Todo": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "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." - }, - "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": { - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - } - } - }, - "tags": [ - { - "name": "Sample" - } - ] -} \ No newline at end of file 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 df00968b6f96..3cf92ecea1c4 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 @@ -16,7 +16,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Todo" + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] } }, "text/xml": { @@ -42,6 +49,11 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } } } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt deleted file mode 100644 index 3f1f5c586add..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.received.txt +++ /dev/null @@ -1,2723 +0,0 @@ -{ - "openapi": "3.1.2", - "info": { - "title": "Sample | localized", - "description": "This is a localized OpenAPI document for français (France).", - "version": "1.0.0" - }, - "servers": [ - { - "url": "http://localhost" - } - ], - "paths": { - "/forms/form-file": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "resume" - ], - "type": "object", - "properties": { - "resume": { - "$ref": "#/components/schemas/IFormFile" - } - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/forms/form-files": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "files" - ], - "type": "object", - "properties": { - "files": { - "$ref": "#/components/schemas/IFormFileCollection" - } - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/forms/form-file-multiple": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "resume", - "files" - ], - "type": "object", - "allOf": [ - { - "type": "object", - "properties": { - "resume": { - "$ref": "#/components/schemas/IFormFile" - } - } - }, - { - "type": "object", - "properties": { - "files": { - "$ref": "#/components/schemas/IFormFileCollection" - } - } - } - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/forms/form-todo": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/forms/forms-pocos-and-files": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "file" - ], - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "type": "object", - "properties": { - "file": { - "$ref": "#/components/schemas/IFormFile" - } - } - } - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/v1/array-of-guids": { - "get": { - "tags": [ - "Sample" - ], - "parameters": [ - { - "name": "guids", - "in": "query", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - } - } - } - } - }, - "/v1/todos": { - "post": { - "tags": [ - "Sample" - ], - "summary": "Creates a new todo item.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/v1/todos/{id}": { - "get": { - "tags": [ - "Sample" - ], - "description": "Returns a specific todo item.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoWithDueDate" - } - } - } - } - } - } - }, - "/v2/users": { - "get": { - "tags": [ - "users" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Sample" - ], - "operationId": "CreateUser", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/xml/type-with-examples": { - "get": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypeWithExamples" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypeWithExamples" - } - } - } - } - } - } - }, - "/xml/todo": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoFomInterface" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/xml/project": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/xml/board": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BoardItem" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/xml/project-record": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectRecord" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/xml/todo-with-description": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoWithDescription" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/Xml": { - "get": { - "tags": [ - "Xml" - ], - "parameters": [ - { - "name": "name", - "in": "query", - "description": "The name of the person.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Returns the greeting.", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - }, - "post": { - "tags": [ - "Xml" - ], - "requestBody": { - "description": "The todo to insert into the database.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/schemas-by-ref/typed-results": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - } - } - } - }, - "/schemas-by-ref/multiple-results": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/schemas-by-ref/iresult-no-produces": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/iresult-with-produces": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - } - } - } - }, - "/schemas-by-ref/primitives": { - "get": { - "tags": [ - "Sample" - ], - "parameters": [ - { - "name": "id", - "in": "query", - "description": "The ID associated with the Todo item.", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "The number of Todos to fetch", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/product": { - "get": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Product" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Product" - } - } - } - } - } - } - }, - "/schemas-by-ref/account": { - "get": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Account" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Account" - } - } - } - } - } - } - }, - "/schemas-by-ref/array-of-ints": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "integer", - "format": "int32" - } - } - } - } - } - } - }, - "/schemas-by-ref/list-of-ints": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "integer", - "format": "int32" - } - } - } - } - } - } - }, - "/schemas-by-ref/ienumerable-of-ints": { - "post": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "integer", - "format": "int32" - } - } - } - } - } - } - }, - "/schemas-by-ref/dictionary-of-ints": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } - } - } - } - } - } - } - }, - "/schemas-by-ref/frozen-dictionary-of-ints": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } - } - } - } - } - } - } - }, - "/schemas-by-ref/shape": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Shape" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/weatherforecastbase": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WeatherForecastBase" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/person": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/category": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Category" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/container": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContainerType" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/root": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Root" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/location": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LocationContainer" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/parent": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ParentObject" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/child": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChildObject" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/json-patch": { - "patch": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json-patch+json": { - "schema": { - "$ref": "#/components/schemas/JsonPatchDocument" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/json-patch-generic": { - "patch": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json-patch+json": { - "schema": { - "$ref": "#/components/schemas/JsonPatchDocument" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/custom-iresult": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CustomIResultImplementor" - } - } - } - } - } - } - }, - "/schemas-by-ref/config-with-generic-lists": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Config" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/project-response": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectResponse" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/subscription": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Subscription" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/nullable-response": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NullableResponseModel" - } - } - } - } - } - } - }, - "/schemas-by-ref/nullable-return-type": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NullableResponseModel" - } - ] - } - } - } - } - } - } - }, - "/schemas-by-ref/nullable-request": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NullableRequestModel" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/complex-nullable-hierarchy": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ComplexHierarchyModel" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/nullable-array-elements": { - "post": { - "tags": [ - "Sample" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NullableArrayModel" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/schemas-by-ref/optional-with-default": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelWithDefaults" - } - } - } - } - } - } - }, - "/schemas-by-ref/nullable-enum-response": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EnumNullableModel" - } - } - } - } - } - } - }, - "/responses/200-add-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - }, - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - } - } - } - } - } - }, - "/responses/200-only-xml": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/xml": { - "schema": { - "$ref": "#/components/schemas/Todo" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/TodoWithDueDate" - } - } - } - } - } - } - }, - "/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-one-of": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Todo" - }, - { - "$ref": "#/components/schemas/TodoWithDueDate" - } - ] - } - } - } - } - } - } - }, - "/responses/triangle": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Triangle" - } - } - } - } - } - } - }, - "/responses/shape": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Shape" - } - } - } - } - } - } - }, - "/getbyidandname/{id}/{name}": { - "get": { - "tags": [ - "Test" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "minLength": 5, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/gettypedresult": { - "get": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MvcTodo" - } - } - } - } - } - } - }, - "/forms": { - "post": { - "tags": [ - "Test" - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "Title": { - "type": "string" - }, - "Description": { - "type": "string" - }, - "IsCompleted": { - "type": "boolean" - } - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/getcultureinvariant": { - "get": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - } - } - } - } - } - }, - "/multi-content-type": { - "get": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MvcTodo" - } - }, - "text/plain": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/one-of": { - "get": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CurrentWeather" - }, - { - "$ref": "#/components/schemas/MvcTodo" - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Account": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - } - } - }, - "AddressDto": { - "required": [ - "relatedLocation" - ], - "type": "object", - "properties": { - "relatedLocation": { - "$ref": "#/components/schemas/LocationDto" - } - } - }, - "BoardItem": { - "required": [ - "name" - ], - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "description": "An item on the board." - }, - "Category": { - "required": [ - "name", - "parent" - ], - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "$ref": "#/components/schemas/Category" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Tag" - } - } - } - }, - "ChildObject": { - "required": [ - "parent" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "parent": { - "$ref": "#/components/schemas/ParentObject" - } - } - }, - "CityResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - }, - "ComplexHierarchyModel": { - "required": [ - "id", - "requiredNested" - ], - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "optionalNested": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NestedModel" - } - ] - }, - "requiredNested": { - "$ref": "#/components/schemas/NestedModel" - }, - "nullableListWithNullableItems": { - "type": [ - "null", - "array" - ], - "items": { - "$ref": "#/components/schemas/NestedModel" - } - } - } - }, - "ComplexType": { - "type": "object", - "properties": { - "description": { - "type": [ - "null", - "string" - ] - }, - "timestamp": { - "type": [ - "null", - "string" - ], - "format": "date-time" - } - } - }, - "Config": { - "type": "object", - "properties": { - "items1": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConfigItem" - } - }, - "items2": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConfigItem" - } - } - } - }, - "ConfigItem": { - "type": "object", - "properties": { - "id": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "lang": { - "type": [ - "null", - "string" - ] - }, - "words": { - "type": [ - "null", - "object" - ] - }, - "break": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "willBeGood": { - "type": [ - "null", - "string" - ] - } - } - }, - "ContainerType": { - "type": "object", - "properties": { - "seq1": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "seq2": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "CurrentWeather": { - "type": "object", - "properties": { - "temperature": { - "maximum": 100.5, - "minimum": -100.5, - "type": "number", - "format": "float", - "default": 0.1 - } - } - }, - "CustomIResultImplementor": { - "required": [ - "content" - ], - "type": "object", - "properties": { - "content": { - "type": "string" - } - } - }, - "EnumNullableModel": { - "required": [ - "requiredEnum" - ], - "type": "object", - "properties": { - "requiredEnum": { - "$ref": "#/components/schemas/TestEnum" - }, - "nullableEnum": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/TestEnum" - } - ] - }, - "listOfNullableEnums": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TestEnum" - } - } - } - }, - "IFormFile": { - "type": "string", - "format": "binary" - }, - "IFormFileCollection": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IFormFile" - } - }, - "Item": { - "type": "object", - "properties": { - "name": { - "type": "array", - "items": { - "type": "string" - } - }, - "value": { - "type": "integer", - "format": "int32" - } - } - }, - "JsonPatchDocument": { - "type": "array", - "items": { - "type": "object", - "oneOf": [ - { - "required": [ - "op", - "path", - "value" - ], - "type": "object", - "properties": { - "op": { - "enum": [ - "add", - "replace", - "test" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "value": { } - }, - "additionalProperties": false - }, - { - "required": [ - "op", - "path", - "from" - ], - "type": "object", - "properties": { - "op": { - "enum": [ - "move", - "copy" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "from": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "required": [ - "op", - "path" - ], - "type": "object", - "properties": { - "op": { - "enum": [ - "remove" - ], - "type": "string" - }, - "path": { - "type": "string" - } - }, - "additionalProperties": false - } - ] - } - }, - "LocationContainer": { - "required": [ - "location" - ], - "type": "object", - "properties": { - "location": { - "$ref": "#/components/schemas/LocationDto" - } - } - }, - "LocationDto": { - "required": [ - "address" - ], - "type": "object", - "properties": { - "address": { - "$ref": "#/components/schemas/AddressDto" - } - } - }, - "ModelWithDefaults": { - "type": "object", - "properties": { - "propertyWithDefault": { - "type": "string" - }, - "nullableWithNull": { - "type": [ - "null", - "string" - ] - }, - "numberWithDefault": { - "type": "integer", - "format": "int32" - }, - "boolWithDefault": { - "type": "boolean" - } - } - }, - "MvcTodo": { - "required": [ - "title", - "description", - "isCompleted" - ], - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "isCompleted": { - "type": "boolean" - } - } - }, - "NestedModel": { - "required": [ - "name" - ], - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "optionalValue": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "deepNested": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ComplexType" - } - ] - } - } - }, - "NullableArrayModel": { - "type": "object", - "properties": { - "nullableArray": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "listWithNullableElements": { - "type": "array", - "items": { - "type": "string" - } - }, - "nullableDictionaryWithNullableValues": { - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "string" - } - } - } - }, - "NullableRequestModel": { - "required": [ - "requiredField" - ], - "type": "object", - "properties": { - "requiredField": { - "type": "string" - }, - "optionalField": { - "type": [ - "null", - "string" - ] - }, - "nullableList": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "nullableDictionary": { - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "string" - } - } - } - }, - "NullableResponseModel": { - "required": [ - "requiredProperty" - ], - "type": "object", - "properties": { - "requiredProperty": { - "type": "string" - }, - "nullableProperty": { - "type": [ - "null", - "string" - ] - }, - "nullableComplexProperty": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ComplexType" - } - ] - } - } - }, - "ParentObject": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "children": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChildObject" - } - } - } - }, - "Person": { - "required": [ - "discriminator" - ], - "type": "object", - "anyOf": [ - { - "$ref": "#/components/schemas/PersonStudent" - }, - { - "$ref": "#/components/schemas/PersonTeacher" - } - ], - "discriminator": { - "propertyName": "discriminator", - "mapping": { - "student": "#/components/schemas/PersonStudent", - "teacher": "#/components/schemas/PersonTeacher" - } - } - }, - "PersonStudent": { - "properties": { - "discriminator": { - "enum": [ - "student" - ], - "type": "string" - }, - "gpa": { - "type": "number", - "format": "double" - } - } - }, - "PersonTeacher": { - "required": [ - "subject" - ], - "properties": { - "discriminator": { - "enum": [ - "teacher" - ], - "type": "string" - }, - "subject": { - "type": "string" - } - } - }, - "Product": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - } - } - }, - "Project": { - "required": [ - "name", - "description" - ], - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "description": "The project that contains Todo items." - }, - "ProjectAddressResponse": { - "required": [ - "city" - ], - "type": "object", - "properties": { - "city": { - "$ref": "#/components/schemas/CityResponse" - } - } - }, - "ProjectBuilderResponse": { - "required": [ - "city" - ], - "type": "object", - "properties": { - "city": { - "$ref": "#/components/schemas/CityResponse" - } - } - }, - "ProjectRecord": { - "required": [ - "name", - "description" - ], - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the project." - }, - "description": { - "type": "string", - "description": "The description of the project." - } - }, - "description": "The project that contains Todo items." - }, - "ProjectResponse": { - "required": [ - "address", - "builder" - ], - "type": "object", - "properties": { - "address": { - "$ref": "#/components/schemas/ProjectAddressResponse" - }, - "builder": { - "$ref": "#/components/schemas/ProjectBuilderResponse" - } - } - }, - "RefProfile": { - "required": [ - "user" - ], - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/RefUser" - } - } - }, - "RefUser": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string" - } - } - }, - "Root": { - "type": "object", - "properties": { - "item1": { - "$ref": "#/components/schemas/Item" - }, - "item2": { - "$ref": "#/components/schemas/Item" - } - } - }, - "Shape": { - "required": [ - "$type" - ], - "type": "object", - "anyOf": [ - { - "$ref": "#/components/schemas/ShapeTriangle" - }, - { - "$ref": "#/components/schemas/ShapeSquare" - } - ], - "discriminator": { - "propertyName": "$type", - "mapping": { - "triangle": "#/components/schemas/ShapeTriangle", - "square": "#/components/schemas/ShapeSquare" - } - } - }, - "ShapeSquare": { - "properties": { - "$type": { - "enum": [ - "square" - ], - "type": "string" - }, - "area": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "ShapeTriangle": { - "properties": { - "$type": { - "enum": [ - "triangle" - ], - "type": "string" - }, - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "Subscription": { - "required": [ - "id", - "primaryUser" - ], - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "primaryUser": { - "$ref": "#/components/schemas/RefProfile" - }, - "secondaryUser": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/RefProfile" - } - ] - } - } - }, - "Tag": { - "required": [ - "name" - ], - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - }, - "TestEnum": { - "type": "integer" - }, - "Todo": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "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." - }, - "TodoFomInterface": { - "required": [ - "name", - "description" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The identifier of the todo.", - "format": "int32" - }, - "name": { - "type": "string", - "description": "The name of the todo." - }, - "description": { - "type": "string", - "description": "A description of the todo." - } - }, - "description": "This is a todo item." - }, - "TodoWithDescription": { - "required": [ - "name", - "description" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The identifier of the todo, overridden.", - "format": "int32" - }, - "name": { - "type": "string", - "description": "The name of the todo, overridden." - }, - "description": { - "type": "string", - "description": "A description of the the todo.\nAnother description of the todo." - } - } - }, - "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": { - "hypotenuse": { - "type": "number", - "format": "double" - }, - "color": { - "type": "string" - }, - "sides": { - "type": "integer", - "format": "int32" - } - } - }, - "TypeWithExamples": { - "type": "object", - "properties": { - "booleanType": { - "type": "boolean", - "example": true - }, - "integerType": { - "type": "integer", - "format": "int32", - "example": 42 - }, - "longType": { - "type": "integer", - "format": "int64", - "example": 1234567890123456789 - }, - "doubleType": { - "type": "number", - "format": "double", - "example": 3.14 - }, - "floatType": { - "type": "number", - "format": "float", - "example": 3.14 - }, - "dateTimeType": { - "type": "string", - "format": "date-time", - "example": "2022-01-01T00:00:00Z" - }, - "dateOnlyType": { - "type": "string", - "format": "date", - "example": "2022-01-01" - } - } - }, - "WeatherForecastBase": { - "required": [ - "$type" - ], - "type": "object", - "anyOf": [ - { - "$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity" - }, - { - "$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries" - }, - { - "$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews" - } - ], - "discriminator": { - "propertyName": "$type", - "mapping": { - "0": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity", - "1": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries", - "2": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews" - } - } - }, - "WeatherForecastBaseWeatherForecastWithCity": { - "required": [ - "city" - ], - "properties": { - "$type": { - "enum": [ - 0 - ], - "type": "integer" - }, - "city": { - "type": "string" - } - } - }, - "WeatherForecastBaseWeatherForecastWithLocalNews": { - "required": [ - "news" - ], - "properties": { - "$type": { - "enum": [ - 2 - ], - "type": "integer" - }, - "news": { - "type": "string" - } - } - }, - "WeatherForecastBaseWeatherForecastWithTimeSeries": { - "required": [ - "summary" - ], - "properties": { - "$type": { - "enum": [ - 1 - ], - "type": "integer" - }, - "date": { - "type": "string", - "format": "date-time" - }, - "temperatureC": { - "type": "integer", - "format": "int32" - }, - "summary": { - "type": "string" - } - } - } - } - }, - "tags": [ - { - "name": "Sample" - }, - { - "name": "users" - }, - { - "name": "Xml" - }, - { - "name": "Test" - } - ] -} \ No newline at end of file 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 13778d9ee92a..3f1f5c586add 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 @@ -1328,7 +1328,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Todo" + "oneOf": [ + { + "$ref": "#/components/schemas/Todo" + }, + { + "$ref": "#/components/schemas/TodoWithDueDate" + } + ] } }, "text/xml": { @@ -1354,6 +1361,11 @@ "schema": { "$ref": "#/components/schemas/Todo" } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDueDate" + } } } } From 9c69c14a4f1eafb662cae910597847de86df5989 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 12 Mar 2026 13:45:55 +0100 Subject: [PATCH 25/41] include hiding inferred-type scenario --- .../sample/Endpoints/MapResponsesEndpoints.cs | 6 +++ ...cument_documentName=responses.verified.txt | 43 +++++++++++++++++++ ...cument_documentName=responses.verified.txt | 43 +++++++++++++++++++ ...cument_documentName=responses.verified.txt | 43 +++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 43 +++++++++++++++++++ 5 files changed, 178 insertions(+) diff --git a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs index 34cbd978bf16..72a86d13c000 100644 --- a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs @@ -11,9 +11,15 @@ 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"); 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 826a7038e600..9973ede92762 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 @@ -36,6 +36,30 @@ } } }, + "/responses/200-add-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, "/responses/200-only-xml": { "get": { "tags": [ @@ -60,6 +84,25 @@ } } }, + "/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": [ 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 44e254bdfabc..12fe7d636f87 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 @@ -36,6 +36,30 @@ } } }, + "/responses/200-add-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, "/responses/200-only-xml": { "get": { "tags": [ @@ -60,6 +84,25 @@ } } }, + "/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": [ 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 3cf92ecea1c4..c522bcf3875e 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 @@ -36,6 +36,30 @@ } } }, + "/responses/200-add-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, "/responses/200-only-xml": { "get": { "tags": [ @@ -60,6 +84,25 @@ } } }, + "/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": [ 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 3f1f5c586add..90a1a0c83264 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 @@ -1348,6 +1348,30 @@ } } }, + "/responses/200-add-xml-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + } + }, "/responses/200-only-xml": { "get": { "tags": [ @@ -1372,6 +1396,25 @@ } } }, + "/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": [ From 85d43c361967c43e4b77620022f455518535d560 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 18 Mar 2026 20:17:58 +0100 Subject: [PATCH 26/41] tests on groups --- ...pointMetadataApiDescriptionProviderTest.cs | 391 ++++-------------- 1 file changed, 72 insertions(+), 319 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 46082f137c3c..da0c2d1cec80 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -1180,11 +1180,20 @@ public void RespectsProducesWithGroupNameExtensionMethod() // Assert var apiDescription = Assert.Single(context.Results); - // RDF infers (200, string, "text/plain") from the `() => ""` return type, - // and .Produces() adds (200, InferredJsonClass, "application/json"). - // Both coexist as IProducesResponseTypeMetadata entries. - Assert.Contains(apiDescription.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); + 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); } @@ -1234,39 +1243,37 @@ public void HandlesProducesWithProducesProblem() provider.OnProvidersExecuted(context); // Assert - // RDF infers (200, string, "text/plain") from `() => ""`, which coexists - // with .Produces(200) since both are IProducesResponseTypeMetadata. Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + 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(new[] { "text/plain" }, GetSortedMediaTypes(responseType)); + 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)); }); } @@ -1292,27 +1299,25 @@ public void HandleMultipleProduces() provider.OnProvidersExecuted(context); // Assert - // RDF infers (200, string, "text/plain") from `() => ""`, which coexists - // with .Produces(200). Both are IProducesResponseTypeMetadata. Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + 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(new[] { "text/plain" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["text/plain"], GetSortedMediaTypes(responseType)); }, responseType => { Assert.Equal(typeof(InferredJsonClass), responseType.Type); Assert.Equal(201, responseType.StatusCode); - Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); }); } @@ -1337,13 +1342,13 @@ public void HandleMultipleProducesWithSameStatusCodeAndDifferentContentTypes() { 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(new[] { "text/html" }, GetSortedMediaTypes(responseType)); + Assert.Equal(["text/html"], GetSortedMediaTypes(responseType)); }); } @@ -1424,7 +1429,7 @@ public void HandleMultipleProducesDeterministicOrdering() // Assert — ordered by StatusCode, then by Type name, content types merged for same (StatusCode, Type) Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + context.Results.SelectMany(r => r.SupportedResponseTypes), responseType => { // (200, InferredJsonClass) — merged from two .Produces calls @@ -1538,355 +1543,103 @@ public void FavorsProducesMetadataOverAttribute() provider.OnProvidersExecuted(context); // Assert - // [ProducesResponseType(typeof(List), 200)] is an attribute (IApiResponseMetadataProvider). - // .Produces(200) and RDF-inferred (200, string) are endpoint metadata - // (IProducesResponseTypeMetadata). Endpoint metadata wins for status 200, so the attribute - // entry is dropped. Both endpoint entries coexist. Assert.Collection( - context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name), + context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), 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[] { "text/plain" }, GetSortedMediaTypes(responseType)); }); } [Fact] - public void CombinesTypedResultWithProducesExtensionAndAttribute_IResultReturn() - { - // Precedence in EndpointMetadataApiDescriptionProvider: - // - .Produces() (IProducesResponseTypeMetadata) wins per status code - // - [ProducesResponseType] (IApiResponseMetadataProvider) fills remaining status codes - // - TypedResults metadata is skipped (IResult + IEndpointMetadataProvider) - var apiDescription = GetApiDescription( - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] - () => TypedResults.Created("https://example.com", new InferredJsonClass()), - httpMethods: ["POST"]); - - // Manually add .Produces() metadata by using builder pattern - var builder = CreateBuilder(); - builder.MapPost("/api/todos", - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] - () => TypedResults.Created("https://example.com", new InferredJsonClass())) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // .Produces(200) wins for status 200 - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); - - // TypedResults.Created adds 201 via IEndpointMetadataProvider — these are NOT skipped - // because the metadata (IProducesResponseTypeMetadata) is distinct from the IResult type itself. - // The Created result type adds metadata that gets processed. - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 201, Type: { } t } && t == typeof(InferredJsonClass)); - - // [ProducesResponseType(typeof(string), 404)] fills in status 404 - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 404, Type: { } t } && t == typeof(string)); - } - - [Fact] - public void CombinesTypedResultWithProducesExtensionAndAttribute_SameStatusCode_ProducesWins() - { - // When .Produces() and [ProducesResponseType] both declare the same status code, - // .Produces() (IProducesResponseTypeMetadata) takes precedence. - var builder = CreateBuilder(); - builder.MapPost("/api/todos", - [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] - () => TypedResults.Ok(new InferredJsonClass())) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // .Produces(200) wins over [ProducesResponseType(typeof(string), 200)] - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); - - // The attribute's string type for 200 should NOT appear - Assert.DoesNotContain(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); - - // TypedResults.Ok() added IProducesResponseTypeMetadata(200, InferredJsonClass) - // to endpoint metadata. Both TypedResults and .Produces() are IProducesResponseTypeMetadata - // entries — there's currently no way to distinguish "framework-inferred" from "user-explicit" - // within ReadEndpointResponseMetadata, so both coexist for the same status code. - // This matches the new multi-produces behavior where different types for the same status code - // are preserved (e.g., .Produces(200, "json").Produces(200, "html")). - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); - } - - [Fact] - public void CombinesProducesExtensionAndAttribute_PocoReturn() - { - // Handler returns a POCO (not IResult), so responseType = typeof(InferredJsonClass). - // Three metadata sources: - // 1. RequestDelegateFactory adds ProducesResponseTypeMetadata(200, InferredJsonClass, "application/json") - // to endpoint metadata for POCO-returning handlers (see RequestDelegateFactory.cs line ~1060). - // This is IProducesResponseTypeMetadata → goes through ReadEndpointResponseMetadata. - // 2. .Produces(201) → ReadEndpointResponseMetadata → {(201, TimeSpan)} - // 3. [ProducesResponseType(typeof(string), 404)] → ReadAttributeResponseMetadata → {(404, string)} - // - // All three survive: RDF-added (200, InferredJsonClass), extension (201, TimeSpan), - // and attribute (404, string). The 200 entry is NOT from the default fallback else branch — - // it's from RDF-added IProducesResponseTypeMetadata in the endpoint metadata. - var builder = CreateBuilder(); - builder.MapGet("/api/todos", - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] - () => new InferredJsonClass()) - .Produces(StatusCodes.Status201Created); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // RDF-added ProducesResponseTypeMetadata(200, InferredJsonClass) from endpoint building - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); - - // .Produces(201) from extension method - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 201, Type: { } t } && t == typeof(TimeSpan)); - - // [ProducesResponseType(typeof(string), 404)] from attribute - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 404, Type: { } t } && t == typeof(string)); - } - - [Fact] - public void CombinesProducesExtensionAndAttribute_PocoReturn_SameStatusCode_ProducesWins() - { - // Handler returns a POCO. Both .Produces() and [ProducesResponseType] declare status 200. - // .Produces() should win for that status code. - var builder = CreateBuilder(); - builder.MapGet("/api/todos", - [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] - () => new InferredJsonClass()) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // .Produces(200) wins over [ProducesResponseType(typeof(string), 200)] - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); - - // The attribute's string type for 200 should NOT appear - Assert.DoesNotContain(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(string)); - - // The inferred InferredJsonClass for 200 also appears (RDF-added entry coexists - // with .Produces(200) since both are IProducesResponseTypeMetadata). - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); - } - - [Fact] - public void ExplicitProducesMatchingInferredType_NotRemovedByOtherProduces() + public void RouteGroup_AndRouteSpecific_SameStatusCodeAndType_MergesContentTypes() { - // Regression test: explicit .Produces() calls that happen to match the inferred return - // type must NOT be removed when a different .Produces() for the same status code appears. - // - // Handler: () => new InferredJsonClass() → inferredType = InferredJsonClass - // Metadata: - // RDF adds (200, InferredJsonClass, "application/json") - // .Produces(200, "text/xml") → explicit, same type as inferred - // .Produces(200, "text/plain") → explicit, different type - // - // All three (200, InferredJsonClass) entries merge, and (200, TimeSpan) coexists. - var builder = CreateBuilder(); - builder.MapGet("/api/todos", () => new InferredJsonClass()) - .Produces(StatusCodes.Status200OK, "text/xml") - .Produces(StatusCodes.Status200OK, "text/plain"); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // (200, InferredJsonClass) must survive — it was explicitly declared via .Produces<>() - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); - - // (200, TimeSpan) coexists for the same status code - Assert.Contains(result.SupportedResponseTypes, - r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); - } - - [Fact] - public void TypedResultsOk_WithProducesSameType_MergesContentTypes() - { - // TypedResults.Ok(obj) adds ProducesResponseTypeMetadata(200, T, "application/json") via IEndpointMetadataProvider. - // .Produces(200, "text/xml") adds another ProducesResponseTypeMetadata(200, T, "text/xml"). - // Same (200, T) → content types merge into a single entry. + // 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(); - builder.MapGet("/api/todos", () => TypedResults.Ok(new InferredJsonClass())) + 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([]); + var context = new ApiDescriptionProviderContext(Array.Empty()); + var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); provider.OnProvidersExecuting(context); provider.OnProvidersExecuted(context); - var result = Assert.Single(context.Results); - - // Single (200, InferredJsonClass) with both content types merged - var responseType = Assert.Single(result.SupportedResponseTypes); - Assert.Equal(200, responseType.StatusCode); + 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 TypedResultsOk_WithProducesDifferentType_BothCoexist() + public void RouteGroup_AndRouteSpecific_SameStatusCodeDifferentType_BothCoexist() { - // TypedResults.Ok(obj) adds (200, T). .Produces(200) adds (200, U). - // Different types for same status code → both coexist. + // 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(); - builder.MapGet("/api/todos", () => TypedResults.Ok(new InferredJsonClass())) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // Both types coexist for status 200 - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(TimeSpan)); - } - - [Fact] - public void TypedResultsOk_NoPayload_WithProduce_BothCoexist() - { - // TypedResults.Ok() adds ProducesResponseTypeMetadata(200, null) - // coexists with .Produces(200, "application/json") + 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 builder = CreateBuilder(); - builder.MapGet("/api/todos", () => TypedResults.Ok()) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); provider.OnProvidersExecuting(context); provider.OnProvidersExecuted(context); - var result = Assert.Single(context.Results); - - // .Produces(200) → (200, InferredJsonClass) - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(InferredJsonClass)); - // TypedResults.Ok() adds metadata with null type → inferred as void → (200, void) with no formats - Assert.Contains(result.SupportedResponseTypes, r => r is { StatusCode: 200, Type: { } t } && t == typeof(void)); + 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 ResultsOk_NoPayload_WithProduce_OnlyProducesSurvives() + public void RouteGroup_AndRouteSpecific_IdenticalMetadata_SingleEntry() { - // Results.Ok() returns IResult — can't infer a type, no metadata added. - // Only .Produces(200) survives. + // Route group and route-specific both add (200, InferredJsonClass, "application/json"). + // Identical (StatusCode, Type) → merged into a single entry, not duplicated. var builder = CreateBuilder(); - builder.MapGet("/api/todos", () => Results.Ok()) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // Only .Produces(200) survives - var responseType = Assert.Single(result.SupportedResponseTypes); - Assert.Equal(200, responseType.StatusCode); - Assert.Equal(typeof(InferredJsonClass), responseType.Type); - Assert.Equal(["application/json"], GetSortedMediaTypes(responseType)); - } - - [Fact] - public void ResultsOk_WithPayload_WithProduce_OnlyProducesSurvives() - { - // Results.Ok(obj) returns IResult — can't see through to the payload type. - // .Produces(200) is the only metadata source. + 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 builder = CreateBuilder(); - builder.MapGet("/api/todos", () => Results.Ok(new InferredJsonClass())) - .Produces(StatusCodes.Status200OK); - var context = new ApiDescriptionProviderContext([]); var endpointDataSource = builder.DataSources.OfType().Single(); var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); provider.OnProvidersExecuting(context); provider.OnProvidersExecuted(context); - var result = Assert.Single(context.Results); - - // Only .Produces(200) — Results.Ok(obj) contributes no metadata - var responseType = Assert.Single(result.SupportedResponseTypes); - Assert.Equal(200, responseType.StatusCode); + 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 TypedResultsCreated_WithProducesSameType_MergesContentTypes() - { - // TypedResults.Created(url, obj) + .Produces(201, "text/xml") with same type → merge - - var builder = CreateBuilder(); - builder.MapPost("/api/todos", () => TypedResults.Created("https://example.com", new InferredJsonClass())) - .Produces(StatusCodes.Status201Created, "text/xml"); - var context = new ApiDescriptionProviderContext([]); - var endpointDataSource = builder.DataSources.OfType().Single(); - var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); - - provider.OnProvidersExecuting(context); - provider.OnProvidersExecuted(context); - - var result = Assert.Single(context.Results); - - // Single (201, InferredJsonClass) with merged content types - var responseType = Assert.Single(result.SupportedResponseTypes); - Assert.Equal(201, responseType.StatusCode); - Assert.Equal(typeof(InferredJsonClass), responseType.Type); - Assert.Equal(["application/json", "text/xml"], GetSortedMediaTypes(responseType)); - } - [Fact] public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() { From 002fd0fa12d6f751446b8203edbccbecaab9407f Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 18 Mar 2026 20:25:33 +0100 Subject: [PATCH 27/41] comments 1 --- .../src/ApiResponseTypeProvider.cs | 4 ++-- ...dpointMetadataApiDescriptionProviderTest.cs | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 21340b1d516f..a882c5cb55b6 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -493,8 +493,8 @@ private static void MergeApiResponseFormats(ApiResponseType existing, ApiRespons } } - // rewrite description - if (newEntry.Description is not null) + // iterating in reverse order to give precedence to higher scope attributes which are processed first + if (existing.Description is null && newEntry.Description is not null) { existing.Description = newEntry.Description; } diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index da0c2d1cec80..fc151f05ff61 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -553,11 +553,11 @@ public void AddsResponseDescription_UsesLastOne() const string expectedBadRequestDescription = "Validation failed for the request"; 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(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 = expectedBadRequestDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = "Last description for bad request")] () => TypedResults.Created("https://example.com", new TimeSpan())); Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); @@ -1544,12 +1544,18 @@ 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)); }); } From 3e76276d77ede9e43140260eab39e31d46ee969b Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 18 Mar 2026 20:27:20 +0100 Subject: [PATCH 28/41] nit 2 --- .../src/EndpointMetadataApiDescriptionProvider.cs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 0e157567bb94..ffb2eafa3b4d 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -417,17 +417,14 @@ private static void AddSupportedResponseTypes( if (supportedResponseTypes.Count > 1) { - var orderedSupportedResponseTypes = supportedResponseTypes + // 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. + supportedResponseTypes = supportedResponseTypes .OrderBy(responseType => responseType.StatusCode) .ThenBy(responseType => responseType.Type?.Name) .ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType) - .ToList(); - - supportedResponseTypes.Clear(); - foreach (var orderedSupportedResponseType in orderedSupportedResponseTypes) - { - supportedResponseTypes.Add(orderedSupportedResponseType); - } + .ToList(); // clears and rewrites the supportedResponseTypes list in-place } static string? GetMatchingResponseTypeDescription(IEnumerable responseMetadataTypes, ApiResponseType apiResponseType) From 98675d19ffde729f241b77f6f44ed86137d2a145 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 27 Mar 2026 13:41:07 +0100 Subject: [PATCH 29/41] change to anyOf --- .../src/Services/OpenApiDocumentService.cs | 4 +-- ...ment_documentName=controllers.verified.txt | 2 +- ...cument_documentName=responses.verified.txt | 4 +-- ...ment_documentName=controllers.verified.txt | 2 +- ...cument_documentName=responses.verified.txt | 4 +-- ...ment_documentName=controllers.verified.txt | 2 +- ...cument_documentName=responses.verified.txt | 4 +-- ...ifyOpenApiDocumentIsInvariant.verified.txt | 6 ++--- .../OpenApiDocumentServiceTests.Responses.cs | 26 +++++++++---------- .../OpenApiSchemaService.ResponseSchemas.cs | 6 ++--- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 1332633eee7a..c45b76e82b67 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -428,7 +428,7 @@ private async Task GetResponseAsync( // 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 a oneOf composite schema. + // will be merged into an anyOf composite schema. var schemasByContentType = new Dictionary>(); foreach (var apiResponseType in apiResponseTypes) @@ -466,7 +466,7 @@ private async Task GetResponseAsync( { IOpenApiSchema finalSchema = schemas.Count == 1 ? schemas[0] - : new OpenApiSchema { OneOf = [.. schemas] }; + : new OpenApiSchema { AnyOf = [.. schemas] }; response.Content[contentType] = new OpenApiMediaType { Schema = finalSchema }; } 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 8c7aa762f177..08e732fa5ec9 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 @@ -160,7 +160,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/CurrentWeather" }, 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 9973ede92762..9df9ba5cb406 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 @@ -16,7 +16,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, @@ -138,7 +138,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, 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 b8528fdff942..8638acb4fa77 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 @@ -160,7 +160,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/CurrentWeather" }, 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 12fe7d636f87..772d8bc1130e 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 @@ -16,7 +16,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, @@ -138,7 +138,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, 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 9007cc2167ab..4ab23e985e25 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 @@ -160,7 +160,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/CurrentWeather" }, 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 c522bcf3875e..623d74c340d6 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 @@ -16,7 +16,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, @@ -138,7 +138,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, 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 90a1a0c83264..2c17ef240fe2 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 @@ -1328,7 +1328,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, @@ -1450,7 +1450,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/Todo" }, @@ -1658,7 +1658,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/CurrentWeather" }, 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 4059f6420515..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 @@ -107,8 +107,8 @@ await VerifyOpenApiDocument(builder, document => var content = Assert.Single(response.Value.Content); Assert.Equal("application/json", content.Key); var schema = content.Value.Schema; - Assert.NotNull(schema.OneOf); - Assert.Equal(2, schema.OneOf.Count); + Assert.NotNull(schema.AnyOf); + Assert.Equal(2, schema.AnyOf.Count); }); } @@ -466,19 +466,19 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("200", response.Key); Assert.Equal(2, response.Value.Content.Count); - // application/json should have a oneOf schema since two types share the same content-type + // 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.OneOf); - Assert.Equal(2, jsonContent.Schema.OneOf.Count); + Assert.NotNull(jsonContent.Schema.AnyOf); + Assert.Equal(2, jsonContent.Schema.AnyOf.Count); - // text/plain should have its own schema without oneOf + // text/plain should have its own schema without anyOf Assert.True(response.Value.Content.TryGetValue("text/plain", out var textContent)); - Assert.Null(textContent.Schema.OneOf); + Assert.Null(textContent.Schema.AnyOf); }); } [Fact] - public async Task GetOpenApiResponse_SupportsThreeTypesForSameContentTypeWithOneOf() + public async Task GetOpenApiResponse_SupportsThreeTypesForSameContentTypeWithAnyOf() { // Arrange var builder = CreateBuilder(); @@ -497,8 +497,8 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("200", response.Key); var content = Assert.Single(response.Value.Content); Assert.Equal("application/json", content.Key); - Assert.NotNull(content.Value.Schema.OneOf); - Assert.Equal(3, content.Value.Schema.OneOf.Count); + Assert.NotNull(content.Value.Schema.AnyOf); + Assert.Equal(3, content.Value.Schema.AnyOf.Count); }); } @@ -557,7 +557,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiResponse_ProducesExtensionMethod_SupportsOneOfForSameContentType() + public async Task GetOpenApiResponse_ProducesExtensionMethod_SupportsAnyOfForSameContentType() { // Arrange var builder = CreateBuilder(); @@ -575,8 +575,8 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("200", response.Key); var content = Assert.Single(response.Value.Content); Assert.Equal("application/json", content.Key); - Assert.NotNull(content.Value.Schema.OneOf); - Assert.Equal(2, content.Value.Schema.OneOf.Count); + 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/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index 5ac52086c44a..71b377136ddb 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 @@ -1072,7 +1072,7 @@ await VerifyOpenApiDocument(actionDescriptor, document => } [Fact] - public async Task GetOpenApiResponse_MvcController_SupportsOneOfForSameContentType() + public async Task GetOpenApiResponse_MvcController_SupportsAnyOfForSameContentType() { var actionDescriptor = CreateActionDescriptor(nameof(MultiProducesController.GetOneOf), typeof(MultiProducesController)); @@ -1083,8 +1083,8 @@ await VerifyOpenApiDocument(actionDescriptor, document => Assert.Equal("200", response.Key); var content = Assert.Single(response.Value.Content); Assert.Equal("application/json", content.Key); - Assert.NotNull(content.Value.Schema.OneOf); - Assert.Equal(2, content.Value.Schema.OneOf.Count); + Assert.NotNull(content.Value.Schema.AnyOf); + Assert.Equal(2, content.Value.Schema.AnyOf.Count); }); } From e74825879a3dfcbabec586fa307b633e91f26bff Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 13:03:14 +0200 Subject: [PATCH 30/41] fix deterministic ordering in api explorer --- .../src/EndpointMetadataApiDescriptionProvider.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index ffb2eafa3b4d..d8146b5aca51 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -420,11 +420,16 @@ private static void AddSupportedResponseTypes( // 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. - supportedResponseTypes = supportedResponseTypes - .OrderBy(responseType => responseType.StatusCode) - .ThenBy(responseType => responseType.Type?.Name) - .ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType) - .ToList(); // clears and rewrites the supportedResponseTypes list in-place + 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) From 8c0a65bf5b59445e79deb773dcbad553c5e2f668 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 13:22:24 +0200 Subject: [PATCH 31/41] regen openapi integration tests --- .../sample/Controllers/TestController.cs | 4 +- .../sample/Endpoints/MapResponsesEndpoints.cs | 2 +- ...ment_documentName=controllers.verified.txt | 52 +++++++++++++++++- ...cument_documentName=responses.verified.txt | 2 +- ...ment_documentName=controllers.verified.txt | 52 +++++++++++++++++- ...cument_documentName=responses.verified.txt | 2 +- ...ment_documentName=controllers.verified.txt | 52 +++++++++++++++++- ...cument_documentName=responses.verified.txt | 2 +- ...ifyOpenApiDocumentIsInvariant.verified.txt | 54 ++++++++++++++++++- 9 files changed, 211 insertions(+), 11 deletions(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 8ca7d939e413..4b2a82ccea47 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -67,10 +67,10 @@ public IActionResult GetMultiContentType() => Ok(new MvcTodo("Title", "Description", true)); [HttpGet] - [Route("/one-of")] + [Route("/any-of")] [ProducesResponseType(typeof(MvcTodo), StatusCodes.Status200OK, "application/json")] [ProducesResponseType(typeof(CurrentWeather), StatusCodes.Status200OK, "application/json")] - public IActionResult GetOneOf() + public IActionResult GetAnyOf() => Ok(new MvcTodo("Title", "Description", true)); public class HttpQuery() : HttpMethodAttribute(["QUERY"]); diff --git a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs index 72a86d13c000..fdee7ad60f3e 100644 --- a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs @@ -24,7 +24,7 @@ public static IEndpointRouteBuilder MapResponseEndpoints(this IEndpointRouteBuil .Produces(StatusCodes.Status200OK, "application/json") .Produces(StatusCodes.Status200OK, "text/xml"); - responses.MapGet("/200-one-of", () => Results.Ok()) + responses.MapGet("/200-any-of", () => Results.Ok()) .Produces(StatusCodes.Status200OK, "application/json") .Produces(StatusCodes.Status200OK, "application/json"); 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 c3b7d31f6845..9805ecdbf3b9 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 @@ -1,4 +1,4 @@ -{ +{ "openapi": "3.0.4", "info": { "title": "Sample | controllers", @@ -206,6 +206,56 @@ } } } + }, + "/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" + } + ] + } + } + } + } + } + } } }, "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 9df9ba5cb406..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 @@ -127,7 +127,7 @@ } } }, - "/responses/200-one-of": { + "/responses/200-any-of": { "get": { "tags": [ "Sample" 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 200a99636029..c2d49c25bac6 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 @@ -1,4 +1,4 @@ -{ +{ "openapi": "3.1.2", "info": { "title": "Sample | controllers", @@ -206,6 +206,56 @@ } } } + }, + "/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" + } + ] + } + } + } + } + } + } } }, "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 772d8bc1130e..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 @@ -127,7 +127,7 @@ } } }, - "/responses/200-one-of": { + "/responses/200-any-of": { "get": { "tags": [ "Sample" 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 d7da7360fe24..e1662e8dd719 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 @@ -1,4 +1,4 @@ -{ +{ "openapi": "3.2.0", "info": { "title": "Sample | controllers", @@ -202,6 +202,56 @@ } } } + }, + "/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" + } + ] + } + } + } + } + } + } } }, "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 623d74c340d6..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 @@ -127,7 +127,7 @@ } } }, - "/responses/200-one-of": { + "/responses/200-any-of": { "get": { "tags": [ "Sample" 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 8590cefbfef1..0175e4e3d0f3 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 @@ -1,8 +1,8 @@ -{ +{ "openapi": "3.1.2", "info": { "title": "Sample | localized", - "description": "This is a localized OpenAPI document for français (France).", + "description": "This is a localized OpenAPI document for français (France).", "version": "1.0.0" }, "servers": [ @@ -1704,6 +1704,56 @@ } } } + }, + "/multi-content-type": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/one-of": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentWeather" + }, + { + "$ref": "#/components/schemas/MvcTodo" + } + ] + } + } + } + } + } + } } }, "components": { From e740fc1628e2b9a5ded5cea97dba6a20c977dae4 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 13:23:10 +0200 Subject: [PATCH 32/41] and fix name of route --- ...izationTests.VerifyOpenApiDocumentIsInvariant.verified.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 0175e4e3d0f3..564741ede82c 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 @@ -1439,7 +1439,7 @@ } } }, - "/responses/200-one-of": { + "/responses/200-any-of": { "get": { "tags": [ "Sample" @@ -1729,7 +1729,7 @@ } } }, - "/one-of": { + "/any-of": { "get": { "tags": [ "Test" From 3ba070be4ee00aa1cdbd3410df39f3115535d56c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 13:59:06 +0200 Subject: [PATCH 33/41] add Debug.Assert to apiresponsetypeprovider scope processing --- src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index a882c5cb55b6..c7e037cd2694 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; @@ -270,7 +271,9 @@ and not ProducesDefaultResponseTypeAttribute 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 From d9854e09ffe0f77c350df25e58ec3539e3412089 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 14:07:23 +0200 Subject: [PATCH 34/41] better in-place doc --- src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index c7e037cd2694..45b546318a2b 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -496,7 +496,8 @@ private static void MergeApiResponseFormats(ApiResponseType existing, ApiRespons } } - // iterating in reverse order to give precedence to higher scope attributes which are processed first + // Keep the first non-null Description encountered. Callers iterate in descending scope + // order, so this preserves the highest-scope description for the (StatusCode, Type) pair. if (existing.Description is null && newEntry.Description is not null) { existing.Description = newEntry.Description; From 7f8e8b95235ac6d4adb04d8b523b6514ec59cb0c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 14:15:37 +0200 Subject: [PATCH 35/41] fix naming of mvc-controller oneOf->anyOf --- .../OpenApiSchemaService.ResponseSchemas.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 b32a661537e9..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 @@ -1144,11 +1144,11 @@ await VerifyOpenApiDocument(actionDescriptor, document => [Fact] public async Task GetOpenApiResponse_MvcController_SupportsAnyOfForSameContentType() { - var actionDescriptor = CreateActionDescriptor(nameof(MultiProducesController.GetOneOf), typeof(MultiProducesController)); + var actionDescriptor = CreateActionDescriptor(nameof(MultiProducesController.GetAnyOf), typeof(MultiProducesController)); await VerifyOpenApiDocument(actionDescriptor, document => { - var operation = document.Paths["/oneof"].Operations[HttpMethod.Get]; + 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); @@ -1177,10 +1177,10 @@ public class MultiProducesController internal IActionResult GetMultiContentType() => throw new NotImplementedException(); [HttpGet] - [Route("/oneof")] + [Route("/anyOf")] [ProducesResponseType(typeof(Todo), StatusCodes.Status200OK, "application/json")] [ProducesResponseType(typeof(Error), StatusCodes.Status200OK, "application/json")] - internal IActionResult GetOneOf() => throw new NotImplementedException(); + internal IActionResult GetAnyOf() => throw new NotImplementedException(); } private class ClassWithObjectProperty From 4daa5a6ff2908e746d8e74b59d0860ac29b3f135 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 14:39:10 +0200 Subject: [PATCH 36/41] explanations + test for corner case --- .../test/ApiResponseTypeProviderTest.cs | 31 +++++++++++++++++++ ...pointMetadataApiDescriptionProviderTest.cs | 13 ++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index cda5f62b7dec..466d2c3674fe 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -1199,6 +1199,37 @@ public void GetApiResponseTypes_DefaultFallback_VoidReturnType_Produces200WithNo 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; diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index bb6439e41228..cade4cf02b31 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -544,11 +544,12 @@ 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: the first non-null Description wins. With descending-scope iteration, + /// this means the highest-scope description sticks. /// [Fact] - public void AddsResponseDescription_UsesLastOne() + public void AddsResponseDescription_FirstNonNullWinsForMerge_LastWinsForFallback() { const string expectedCreatedDescription = "A new item was created"; const string expectedBadRequestDescription = "Validation failed for the request"; @@ -565,6 +566,10 @@ public void AddsResponseDescription_UsesLastOne() 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); @@ -575,6 +580,8 @@ public void AddsResponseDescription_UsesLastOne() var badRequestResponseType = apiDescription.SupportedResponseTypes[1]; + // For status 400 (no endpoint claim), both attribute entries are merged and the FIRST + // non-null Description wins per MergeApiResponseFormats. Assert.Equal(400, badRequestResponseType.StatusCode); Assert.Equal(typeof(void), badRequestResponseType.Type); Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType); From af004d89f3151d14280602eb53f735defa2ad403 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 4 May 2026 16:40:51 +0200 Subject: [PATCH 37/41] make it readonly --- .../Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs index 0476d6049e1f..d783d1b5916e 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseMetadataProviderWithScope.cs @@ -3,7 +3,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer; -internal struct ApiResponseMetadataProviderWithScope(IApiResponseMetadataProvider provider, int scope) +internal readonly struct ApiResponseMetadataProviderWithScope(IApiResponseMetadataProvider provider, int scope) { public IApiResponseMetadataProvider Provider { get; } = provider; public int Scope { get; } = scope; From dbd3ad9f0986ea540e9e5b1af4f9e4caa507d907 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 13 May 2026 21:08:29 +0200 Subject: [PATCH 38/41] merge descriptions --- .../src/ApiResponseTypeProvider.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 45b546318a2b..e74c0ee93616 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -279,7 +279,7 @@ and not ProducesDefaultResponseTypeAttribute // Same scope, same key: merge content types if (results.TryGetValue(key, out var existingEntry)) { - MergeApiResponseFormats(existingEntry, apiResponseType); + MergeApiResponse(existingEntry, apiResponseType); } else { @@ -355,7 +355,7 @@ internal static Dictionary ReadEndpointResponseMet // Same (statusCode, type): merge content types. // Example: .Produces(200, "json").Produces(200, "xml") // → (200, Product) with [json, xml] - MergeApiResponseFormats(existingEntry, apiResponseType); + MergeApiResponse(existingEntry, apiResponseType); } else { @@ -486,7 +486,7 @@ internal static void CalculateResponseFormatForType(ApiResponseType apiResponse, return declaredReturnType; } - private static void MergeApiResponseFormats(ApiResponseType existing, ApiResponseType newEntry) + private static void MergeApiResponse(ApiResponseType existing, ApiResponseType newEntry) { foreach (var format in newEntry.ApiResponseFormats) { @@ -496,11 +496,14 @@ private static void MergeApiResponseFormats(ApiResponseType existing, ApiRespons } } - // Keep the first non-null Description encountered. Callers iterate in descending scope - // order, so this preserves the highest-scope description for the (StatusCode, Type) pair. - if (existing.Description is null && newEntry.Description is not null) + // 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 = newEntry.Description; + existing.Description = string.IsNullOrEmpty(existing.Description) + ? newEntry.Description + : existing.Description + "\n\n" + newEntry.Description; } } From 3dec050083d662d12a0c617c43fc8d619e7cf45f Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 13 May 2026 21:13:52 +0200 Subject: [PATCH 39/41] fix test validating merge --- ...ndpointMetadataApiDescriptionProviderTest.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index cade4cf02b31..7831233bf860 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -545,21 +545,22 @@ public void WithEmptyMethodBody_AddsResponseDescription() /// /// Description policy for the (StatusCode, Type) pair when multiple ProducesResponseType - /// attributes match: the first non-null Description wins. With descending-scope iteration, - /// this means the highest-scope description sticks. + /// attributes match: descriptions are merged /// [Fact] - public void AddsResponseDescription_FirstNonNullWinsForMerge_LastWinsForFallback() + 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")] [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "Second description")] [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Description = "Last description for bad request")] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = firstBadRequestDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = secondBadRequestDescription)] () => TypedResults.Created("https://example.com", new TimeSpan())); Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); @@ -580,8 +581,8 @@ public void AddsResponseDescription_FirstNonNullWinsForMerge_LastWinsForFallback var badRequestResponseType = apiDescription.SupportedResponseTypes[1]; - // For status 400 (no endpoint claim), both attribute entries are merged and the FIRST - // non-null Description wins per MergeApiResponseFormats. + // 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); From b06df29724f1e3343d757239fc94ed883f112352 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 14 May 2026 10:34:14 +0200 Subject: [PATCH 40/41] add duplicate description case for verification --- .../sample/Controllers/TestController.cs | 7 +++++++ ...ment_documentName=controllers.verified.txt | 19 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 19 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 19 +++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 19 +++++++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 4b2a82ccea47..ca93bfcdeee8 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -73,6 +73,13 @@ public IActionResult GetMultiContentType() 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/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 9805ecdbf3b9..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 @@ -256,6 +256,25 @@ } } } + }, + "/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=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index c2d49c25bac6..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 @@ -256,6 +256,25 @@ } } } + }, + "/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=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index e1662e8dd719..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 @@ -252,6 +252,25 @@ } } } + }, + "/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/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index 564741ede82c..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 @@ -1754,6 +1754,25 @@ } } } + }, + "/dup-description": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "Use it!\n\nReturns a Todo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } } }, "components": { From 9ea28189d2aba33ffb35d7c0d46d15fda61f835b Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 14 May 2026 10:37:07 +0200 Subject: [PATCH 41/41] fix nit formatting --- .../EndpointMetadataApiDescriptionProviderTest.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 7831233bf860..691146128fcb 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -556,12 +556,12 @@ public void AddsResponseDescription_ConcatenatesOnMerge_LastWinsForFallback() var expectedBadRequestDescription = firstBadRequestDescription + "\n\n" + secondBadRequestDescription; var apiDescription = GetApiDescription( - [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())); + [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);