ApiExplorer and OpenAPI unions support#67001
Conversation
There was a problem hiding this comment.
Pull request overview
This PR expands ASP.NET Core’s union-type coverage across MVC ApiExplorer and the built-in OpenAPI document/schema generation by adding targeted unit tests plus end-to-end integration snapshots via the OpenAPI sample app.
Changes:
- Added ApiExplorer tests validating union response-type inference and coexistence/merge semantics when multiple types share the same status code.
- Added OpenAPI schema/document tests validating
anyOfemission for union types (including nested/contained unions and multi-Producesscenarios). - Extended the OpenAPI sample + integration snapshots to include a new “unions” document and representative union endpoints for regression coverage.
Show a summary per file
| File | Description |
|---|---|
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.Unions.cs | Adds shared union-shaped types (primitive/object cases + container) used by OpenAPI tests and sample. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.UnionSchemas.cs | Adds schema-service tests verifying unions produce component $ref + anyOf (response + request body + typed results). |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.UnionResponses.cs | Adds document-service tests verifying multi-response-type merging into outer anyOf without flattening union internals. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt | Updates invariant integration snapshot to include union endpoints and schemas. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt | Adds OpenAPI 3.2 snapshot for the new “unions” document. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt | Updates controller snapshot to include union-related controller routes/schemas. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt | Adds OpenAPI 3.1 snapshot for the new “unions” document. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt | Updates controller snapshot to include union-related controller routes/schemas. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt | Adds OpenAPI 3.0 snapshot for the new “unions” document. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt | Updates controller snapshot to include union-related controller routes/schemas. |
| src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs | Registers the new “unions” document for integration testing across spec versions. |
| src/OpenApi/sample/Sample.csproj | Compiles the new union shared-types file into the OpenAPI sample app for snapshot generation. |
| src/OpenApi/sample/Program.cs | Registers and maps the new unions OpenAPI document + endpoints in the sample app. |
| src/OpenApi/sample/Endpoints/MapUnionsEndpoints.cs | Adds minimal API endpoints that exercise union responses/requests and multi-Produces combinations. |
| src/OpenApi/sample/Controllers/TestController.cs | Adds controller endpoints returning unions and multiple ProducesResponseType entries for union scenarios. |
| src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.Unions.cs | Adds ApiExplorer unit tests validating union response-type behavior in endpoint metadata processing. |
| src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs | Makes the test class partial to split union coverage into a dedicated file. |
Copilot's findings
- Files reviewed: 17/17 changed files
- Comments generated: 0
| } | ||
| ] | ||
| }, | ||
| "UnionPetKitten": { |
There was a problem hiding this comment.
Not sure what's correct for the schema, but these sub schemas (UnionPetKitten and UnionPetPuppy) don't have "type": "object"
There was a problem hiding this comment.
We have a precedent of same generation for JSON Polymorphic types.
[JsonDerivedType(typeof(Triangle), typeDiscriminator: "triangle")]
[JsonDerivedType(typeof(Square), typeDiscriminator: "square")]
internal abstract class Shape
{
public string Color { get; set; } = string.Empty;
public int Sides { get; set; }
}
internal class Triangle : Shape
{
public double Hypotenuse { get; set; }
}
internal class Square : Shape
{
public double Area { get; set; }
}generate similar schema without "type": "object" in derived types:
"Shape": {
"required": ["$type"],
"type": "object", // ← parent: type:object + anyOf
"anyOf": [
{ "$ref": "#/components/schemas/ShapeTriangle" },
{ "$ref": "#/components/schemas/ShapeSquare" }
],
"discriminator": { ... }
},
"ShapeSquare": { // ← case schema: NO "type": "object"
"properties": {
"$type": { "enum": ["square"], "type": "string" },
"area": { "type": "number", ... }
}
}In comparison to union:
"UnionPet": {
"type": "object", // ← parent: type:object + anyOf
"anyOf": [
{ "$ref": "#/components/schemas/UnionPetKitten" },
{ "$ref": "#/components/schemas/UnionPetPuppy" }
]
},
"UnionPetKitten": { // ← case schema: NO "type": "object"
"required": ["name", "lives"],
"properties": { ... }
}There was a problem hiding this comment.
What happens if "Kitten" is used elsewhere? Does it get its own schema, separate from "UnionPetKitten"? Seems like it will have to, but I wonder if that's what we want.
There was a problem hiding this comment.
For JsonPolymorphic types, the child class of a polymorphic type does have two schemas -- one "standalone" and one that is part of the polymorphic type. But I think that's by design, because the one in the polymorphic type gains a "$type" property. That's not the case for unions.
There was a problem hiding this comment.
I've chatted with @eiriktsarpalis and we agreed we should change it to emit "type": "object". STJ actually correctly emits the type: object but on the union type using factoring schemas.
And since aspnetcore's OpenAPI tries to rewrite the anyOf cases into $refs, we are responsible for restoring this type: object ourselves here. I've applied a fix, and regenerated the tests.
There was a problem hiding this comment.
thanks for raising concern @mikekistler - I agree. I'll try to fix it now as well.
There was a problem hiding this comment.
A demonstration of what I changed the behavior to. Let me know if anyone has objections on this. Polymorphic types behavior remain unchanged
record Kitten(string Name, int Lives);
record Puppy(string Name, string Breed);
union UnionPet(Kitten, Puppy);
app.MapGet("/pet", () => default(UnionPet));
app.MapGet("/kitten", () => default(Kitten));gives
{
"Kitten": { <<--- has a name as of its own type (original name "Kitten", not "UnionPetKitten")
"type": "object", <<--- has type:object
"required": ["name", "lives"],
"properties": {
"name": { "type": "string" },
"lives": { "type": "integer", "format": "int32" }
}
},
"Puppy": { <<--- has a name as of its own type (original name "Puppy", not "UnionPetPuppy")
"type": "object", <<--- has type:object
"required": ["name", "breed"],
"properties": {
"name": { "type": "string" },
"breed": { "type": "string" }
}
},
"UnionPet": {
"type": "object",
"anyOf": [
{ "$ref": "#/components/schemas/Kitten" }, <<----- uses original type names
{ "$ref": "#/components/schemas/Puppy" } <<----- uses original type names
]
}
}There was a problem hiding this comment.
I think that makes sense. Now we're changing non-test code!
There was a problem hiding this comment.
Now we're changing non-test code!
If that is a hint I should change the PR name from "verify" now - I've done it :P
|
/ba-g unrelated macOS failures |
…acOS VSD hang
Merge-downgrade of eng/Version.Details.{xml,props} + global.json to dotnet/dotnet
codeflow commit 8d5666c (keeps arcade/Wix + main-only deps at main, no dep removal).
Adds the target-era darc-pub-dotnet-extensions feed to NuGet.config so rolled-back
package versions (e.g. Microsoft.Extensions.Caching.Hybrid 10.4.1) resolve.
Reverts the macOS PR-check Helix queue (dotnet#63531) back to OSX.15.Amd64.Open.
Targeted API-break fixes coherent with the older runtime: rolls the 2
CertificateGeneration files back to the target (pre-dotnet#66727 Process.Run) and removes
the JsonTypeInfoKind.Union block (pre-dotnet#67001) in OpenApi.
Draft probe PR for runtime mac-hang bisection. Not for merge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Thanks to #65650 merged before, union support it trivial now. This PR validates and extends test coverage for C# union types through MVC ApiExplorer and OpenAPI.
API Explorer is the core component, which produces metadata from code definition of minimal api or controllers, which is consumed by many libraries like OpenAPI (aspnetcore's), Swashbuckle, NSwag and etc. Since after #65650 multiple types may be produced by API Explorer per same statusCode and contentType, I was wondering if unions should not be a separate type, but its case types should expand in API Explorer. However, I found that Swashbuckle and NSwag (and other external consumers) of API Explorer do not support multiple response types properly as we do now, so it will break them. I have started the conversation in the issue - please comment on that if you see value in implementing this differently: #66544 (comment)
Component naming: unions vs. polymorphism
For polymorphism (e.g.
Shape→Square,Circle), each derived schema is lifted to its own component using a prefixed name (ShapeSquare,ShapeCircle). The prefix is required because the derived schemas carry a$typediscriminator and are structurally different from any standaloneSquare/Circleschema.Unions don't have that discriminator — a case schema is structurally identical to the standalone type. So for
union UnionPet(Kitten, Puppy)we lift the cases under their bare names (Kitten,Puppy) and the standaloneKittenendpoint reuses the same#/components/schemas/Kitten, instead of producing a duplicateUnionPetKittencomponent. Detection of union case is done viaJsonTypeInfoKind.Unionon the parent. The parent schema is tagged withx-schema-is-unionduringApplySchemaReferenceId, and theAnyOfwalker inOpenApiSchemaServiceskips the prefix when that marker is set.Closes #66544