MVC+OpenAPI: Support multiple Produces for same status code / content-type#65650
MVC+OpenAPI: Support multiple Produces for same status code / content-type#65650DeagleGross wants to merge 31 commits intodotnet:mainfrom
Produces for same status code / content-type#65650Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates MVC ApiExplorer and the OpenAPI document generator to support multiple ProducesResponseType/Produces entries that share the same HTTP status code, including scenarios with multiple CLR response types and/or multiple content types per status code.
Changes:
- MVC ApiExplorer now keys response metadata by a composite
ResponseKey(status code + CLR type + content type) and applies deterministic ordering. - OpenAPI generation groups
SupportedResponseTypesby status code and emits multiplecontententries per response; when multiple CLR types map to the same content type, it emits aoneOfschema. - Adds/updates MVC + Minimal API test coverage and sample endpoints/controllers to validate multi-content-type and
oneOfbehaviors.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs | Introduces ResponseKey and updates response metadata merging/dedup + deterministic ordering. |
| src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs | Updates endpoint metadata merging logic to preserve multiple responses per status/type/content-type and sorts output deterministically. |
| src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs | Adds/adjusts tests for multiple produces entries sharing status codes (types/content-types). |
| src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs | Adds tests ensuring multiple produces entries with same status code are preserved for different content-types/types. |
| src/OpenApi/src/Services/OpenApiDocumentService.cs | Groups response types by status code and emits multiple content types; merges same-content-type schemas via oneOf. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs | Strengthens assertions around oneOf and adds coverage for mixed same-content-type + different-content-type merges. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs | Updates controller action descriptor construction (adds filter descriptors derived from endpoint metadata). |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs | Adds MVC controller-based regression tests for multi-content-type and same-content-type oneOf. |
| src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs | Adds sample minimal API endpoints demonstrating multi content types and oneOf usage. |
| src/OpenApi/sample/Controllers/TestController.cs | Adds sample MVC endpoints demonstrating multi content types and oneOf usage. |
martincostello
left a comment
There was a problem hiding this comment.
Would be good to also add at least one Verify test with a snapshot to see how the OpenAPI document itself renders when this is used.
Hey, thanks for looking into this PR! I have adjusted minimal api definition responses.MapGet("/200-multi-content-type", () => Results.Ok())
.Produces<TodoWithDueDate>(StatusCodes.Status200OK, "application/json")
.Produces<Todo>(StatusCodes.Status200OK, "text/xml");
responses.MapGet("/200-one-of", () => Results.Ok())
.Produces<Todo>(StatusCodes.Status200OK, "application/json")
.Produces<TodoWithDueDate>(StatusCodes.Status200OK, "application/json");to be generated into such openapi doc section (properly distinguishing per content-type in first case, and doing /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'The same goes for controller approach: [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));translates into /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'and swagger UI shows it properly for same status-code, but different content-type: but does not show it well for same status-code and content-type due to limitation from swagger UI (swagger-api/swagger-ui#3803): |
|
Any template test |
mikekistler
left a comment
There was a problem hiding this comment.
The current design to emit oneOf for multiple produces with the same status code and content type is flawed, as I explained in my comments.
This might be fixed by just changing this to anyOf, but I'm worried about how this will mesh with the upcoming support for C# unions.
I think the safer course of action at this point is to limit this change to support multiple produces with the same status code but different content types.
Support for multiple produces with the same status code and content type can be added later, once we understand how it could be affected by C# unions.
|
Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime. |


Problem
Both ApiExplorer and OpenAPI do not support multiple
ProducesResponseTypeper single endpoint on the same status-code/content-type. OpenAPI consumes ApiExplorer definitions, and also allows a single response type.Controllers processing flow today:
On Controller's action definition example
It will keep a single entry per statusCode, and latest (higher scope like from action) survives.
Minimal API processing flow today:
Example with minimal api:
It keeps the latest .Produces per status code similar to the controllers. For this example only 3 entries survive (note that void types dont persist):
Requirements to implementation
Controllers semantics
Rule 1: Scope precedence (per status code)
Skip the entry entirely if met the lower scope and same status code as defined in the higher scope.
Rule 2: Same scope merging (per status code + type)
Merge the content-types on the same status code + type, and different content-types. Scope has to be the same.
Rule 3:
[Produces]sets shared content types (highest scope wins)Producesdefines shared content-types, and it has the priority. Shared content types are applied after all attributes are processed, to anyApiResponseTypethat doesn't already have formats populated.Rule 4:
[ProducesResponseType]gets its own content typesEach
ProducesResponseTypekeeps its own non-shared content-types, so we put them during processing on theApiResponseType.Rule 5: Type inference for void
When met
Type == typeof(void):ProducesErrorResponseType, orvoidIsDefaultResponseis set and true → usedefaultErrorType(voidby default)Rule 6: Endpoint metadata vs attribute metadata
Attribute entries take precedence: for any status code claimed by attributes, all endpoint entries with that status code are dropped. Endpoint entries for unclaimed status codes pass through.
Rule 7: Default fallback
If no entries survive (empty dictionary) and the return type is known → add a single [(200, returnType)] entry.
Rule 8: Deterministic ordering
Final output is ordered by
StatusCode→Type→ContentTypeExample:
Has the following output:
200 Foo [application/json, text/plain]→ used [200 Foo, application/json] from action, and merged with [200 Foo text/plain] from action as well200 Bar text/xml→ does not conflict per (statusCode, type) so persists404 Error application/json→ is taken from action scope, shares theapplication/jsoncontent-type fromProducesattribute.404 Errordefined on controller level is ignored due to lower scope (controller).Minimal API semantics
Minimal API is different from controllers. The rules are similar:
Special rules exist around TypedResults and Results:
Results
Request Delegate Factory sees IResult as the lambda return type → cannot infer the payload type. So both
Results.Ok()andResults.Ok(new Product())will not add anyProducesResponseTypeMetadata.TypedResults
TypedResults is designed specifically to workaround this, and the semantics are:
TypedResults.Ok()returns(200, null)TypedResults.Ok(new Product())returns(200, Product, "json")TypedResults.Created(url, new Product())returns(201, Product, "json")Inferred Type
However, if there is no
ResultsnorTypedResultsusage, and the type is explicitly known, then we will include that in the supported response types:app.MapGet("/", () => "hello world");outputs(200, string, "text/plain")Example:
Has the following output:
200 Product application/json, text/plain→ added from.Produces<Product>(json)and then merged with.Produces<Product>("text/plain")200 Summary text/xml→ does not conlfict per (statusCode, type), so added404 ProblemDetails500 ProblemDetailsAnd in case the inferredType usage:
Will drop first
.Produces(200)which uses inferredType (=> new Product()) in favor of explicit.Produces<Customer>(200, "text/xml")OpenAPI changes
OpenAPI layer is changed to support multiple response types as well. Unfortunately,
ApiResponseTypecontains statusCode+Type+Formats(content-types), but the distinction order in OpenAPI is 1) statusCode; 2) content-Type. So we simply get theApiResponseTypesfrom ApiExplorer, group by statusCode, then build up a separate collection "schema<->type". And output them one by one in the result schema:Change to AnyOf:
OneOfis not suitable for multiple output types. Look at example below:In case
{"id": 1, "title": "buy milk", "dueDate": "2026-04-04" }is supplied, bothTodoandTodoWithDueDatewill be valid, butoneOfspecifically tells a single one output type should match.That is why we cannot go forward with
oneOfand we should make a change toanyOf.anyOfis supported from the same version of OpenAPI schema asoneOf, so there will not be any breaking change from usage perspective.Going forward we will probably still use
oneOfto support unions (when working on #64599), but it should be valid to support multiple output types considering no unions are used today.Fixes #56177, #55412