Skip to content

MVC+OpenAPI: Support multiple Produces for same status code / content-type#65650

Open
DeagleGross wants to merge 31 commits intodotnet:mainfrom
DeagleGross:dmkorolev/multiple-produces
Open

MVC+OpenAPI: Support multiple Produces for same status code / content-type#65650
DeagleGross wants to merge 31 commits intodotnet:mainfrom
DeagleGross:dmkorolev/multiple-produces

Conversation

@DeagleGross
Copy link
Copy Markdown
Member

@DeagleGross DeagleGross commented Mar 4, 2026

Problem

Both ApiExplorer and OpenAPI do not support multiple ProducesResponseType per single endpoint on the same status-code/content-type. OpenAPI consumes ApiExplorer definitions, and also allows a single response type.

Controllers processing flow today:

  1. Read endpoint metadata → Dictionary<int, ApiResponseType> (endpointTypes)
  2. Read filter attributes → Dictionary<int, ApiResponseType> (attributeTypes)
  3. For each attributeType: endpointTypes[statusCode] = attributeType ← OVERWRITE
  4. If dictionary is empty and return type is known → add default 200
  5. Apply content-type negotiation to all entries
  6. Return dictionary.Values (unordered)

On Controller's action definition example

[ProducesResponseType(typeof(Error), 404)]  // controller scope -> taken since no status code conflict
[ProducesResponseType(typeof(Error), 200)]  // controller scope -> overwritten
public class MyController
{
    [ProducesResponseType(typeof(Foo), 200)]  // action scope -> overwritten
    [ProducesResponseType(typeof(Bar), 200)]  // action scope -> overwrites "Foo" one (above)
    public IActionResult Get() { ... }
}

It will keep a single entry per statusCode, and latest (higher scope like from action) survives.

Minimal API processing flow today:

  1. Read IApiResponseMetadataProvider → Dictionary<int, ApiResponseType> (attributeTypes)
  2. Read IProducesResponseTypeMetadata → Dictionary<int, ApiResponseType> (endpointTypes)
  3. Merge: endpointTypes.Values.Concat(attributeTypes.Values) (endpoint wins if both exist)
  4. Deduplicate by StatusCode: first entry per status code is kept
  5. If empty → add default 200
  6. Return (unordered)

Example with minimal api:

app.MapGet("/items/{id}", (int id) =>
{
    if (id < 0) return Results.BadRequest();
    if (id == 0) return Results.NotFound();
    return Results.Ok(new Product { Id = id });
})
.Produces<Product>(200)                          // (200, Product)
.Produces<Customer>(200, "text/xml")             // (200, Customer) ← OVERWRITES Product!
.Produces(404)                                   // (404, void → no type (dropped))
.ProducesProblem(400)                            // (400, ProblemDetails)
.ProducesProblem(500);                           // (500, ProblemDetails)

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):

  1. 200 Product
  2. 400 ProblemDetails
  3. 500 ProblemDetails

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)

Produces defines shared content-types, and it has the priority. Shared content types are applied after all attributes are processed, to any ApiResponseType that doesn't already have formats populated.

Rule 4: [ProducesResponseType] gets its own content types

Each ProducesResponseType keeps its own non-shared content-types, so we put them during processing on the ApiResponseType.

Rule 5: Type inference for void

When met Type == typeof(void):

  • Status 200 or 201 → infer from the action's return type
  • 4xx client error → use type defined in ProducesErrorResponseType, or void
  • if IsDefaultResponse is set and true → use defaultErrorType (void by 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 StatusCodeTypeContentType

Example:

[Produces("application/json")]                                    // controller, scope=5
[ProducesResponseType(typeof(Error), 404)]                        // controller, scope=5
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() { ... }
}

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 well
  • 200 Bar text/xml → does not conflict per (statusCode, type) so persists
  • 404 Error application/json → is taken from action scope, shares the application/json content-type from Produces attribute.

404 Error defined on controller level is ignored due to lower scope (controller).

Minimal API semantics

Minimal API is different from controllers. The rules are similar:

  • There are no scopes — all metadata lives at the same level (scope=0 for attribute path)
  • There are no filter descriptors — metadata comes from endpoint metadata collection
  • Inferred type — derived from the lambda return type
  • Endpoint metadata wins over attributes. For any status code claimed by endpoint metadata, attribute entries with that status code are dropped

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() and Results.Ok(new Product()) will not add any ProducesResponseTypeMetadata.

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 Results nor TypedResults usage, 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:

app.MapGet("/items/{id}", (int id) => id > 0
        ? Results.Ok(new Product { Id = id })
        : Results.NotFound())
    .Produces<Product>(200, "application/json")
    .Produces<Summary>(200, "text/xml")
    .Produces<Product>(200, "text/plain")      // same (200, Product) — merges
    .ProducesProblem(404)
    .ProducesProblem(500);

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 added
  • 404 ProblemDetails
  • 500 ProblemDetails

And in case the inferredType usage:

app.MapGet("/product", () => new Product())
    .Produces(200)                               // type=null → inferred as Product
    .Produces<Customer>(200, "text/xml");        // explicit Customer

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, ApiResponseType contains statusCode+Type+Formats(content-types), but the distinction order in OpenAPI is 1) statusCode; 2) content-Type. So we simply get the ApiResponseTypes from ApiExplorer, group by statusCode, then build up a separate collection "schema<->type". And output them one by one in the result schema:

responses:
  '200':                      # grouped by status code
    description: OK
    content:
      application/json:       # then by content type
        schema-a: { ... }
        schema-b: { ... }
      text/plain:
        schema: { ... }
  '404':
    description: Not Found
    content:
      application/json:
        schema: { ... }

Change to AnyOf:

OneOf is not suitable for multiple output types. Look at example below:

          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Todo'
                  - $ref: '#/components/schemas/TodoWithDueDate'

In case {"id": 1, "title": "buy milk", "dueDate": "2026-04-04" } is supplied, both Todo and TodoWithDueDate will be valid, but oneOf specifically tells a single one output type should match.

That is why we cannot go forward with oneOf and we should make a change to anyOf. anyOf is supported from the same version of OpenAPI schema as oneOf, so there will not be any breaking change from usage perspective.

Going forward we will probably still use oneOf to 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

@DeagleGross DeagleGross self-assigned this Mar 4, 2026
@DeagleGross DeagleGross requested review from a team and halter73 as code owners March 4, 2026 16:45
Copilot AI review requested due to automatic review settings March 4, 2026 16:45
@github-actions github-actions Bot added the area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates label Mar 4, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SupportedResponseTypes by status code and emits multiple content entries per response; when multiple CLR types map to the same content type, it emits a oneOf schema.
  • Adds/updates MVC + Minimal API test coverage and sample endpoints/controllers to validate multi-content-type and oneOf behaviors.

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.

Copy link
Copy Markdown
Member

@martincostello martincostello left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@DeagleGross
Copy link
Copy Markdown
Member Author

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 .verified files. On the sample app I can see

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 oneOf on identical statuscode+content-type):

 /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:
image

but does not show it well for same status-code and content-type due to limitation from swagger UI (swagger-api/swagger-ui#3803):
image

Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
@wtgodbe
Copy link
Copy Markdown
Member

wtgodbe commented Mar 6, 2026

Any template test dotnet-ef failures that look like MethodNotFound are unrelated - I'm investigating in #65676

Comment thread src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs Outdated
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Comment thread src/OpenApi/src/Services/OpenApiDocumentService.cs
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Comment thread src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Comment thread src/OpenApi/src/Services/OpenApiDocumentService.cs
Comment thread src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs Outdated
Copy link
Copy Markdown
Contributor

@mikekistler mikekistler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/OpenApi/src/Services/OpenApiDocumentService.cs
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service Bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Mar 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Aggregate multiple Produces for same status code but different content-types

7 participants