Skip to content

Commit 4b9bf25

Browse files
authored
Set OpenAPI discriminators for JSON polymorphic types (#56466)
* Set OpenAPI discriminators for JSON polymorphic types * Use MapPost instead of MapGet for sample endpoints
1 parent a46fa59 commit 4b9bf25

15 files changed

+644
-34
lines changed

src/OpenApi/sample/Program.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
.Produces<Todo>(contentType: "text/xml");
9292

9393
responses.MapGet("/triangle", () => new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 });
94-
responses.MapGet("/shape", () => new Shape { Color = "blue", Sides = 4 });
94+
responses.MapGet("/shape", Shape () => new Triangle { Color = "blue", Sides = 4 });
9595

9696
schemas.MapGet("/typed-results", () => TypedResults.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }));
9797
schemas.MapGet("/multiple-results", Results<Ok<Triangle>, NotFound<string>> () => Random.Shared.Next(0, 2) == 0
@@ -108,6 +108,9 @@
108108
schemas.MapPost("/ienumerable-of-ints", (IEnumerable<int> values) => values.Count());
109109
schemas.MapGet("/dictionary-of-ints", () => new Dictionary<string, int> { { "one", 1 }, { "two", 2 } });
110110
schemas.MapGet("/frozen-dictionary-of-ints", () => ImmutableDictionary.CreateRange(new Dictionary<string, int> { { "one", 1 }, { "two", 2 } }));
111+
schemas.MapPost("/shape", (Shape shape) => { });
112+
schemas.MapPost("/weatherforecastbase", (WeatherForecastBase forecast) => { });
113+
schemas.MapPost("/person", (Person person) => { });
111114

112115
app.MapControllers();
113116

src/OpenApi/sample/Sample.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
</ItemGroup>
2323

2424
<ItemGroup>
25-
<Compile Include="../test/SharedTypes.cs" />
25+
<Compile Include="../test/Shared/SharedTypes.cs" />
26+
<Compile Include="../test/Shared/SharedTypes.Polymorphism.cs" />
2627
</ItemGroup>
2728

2829
<!-- Required to generated trimmable Map-invocations. -->

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,17 +324,23 @@ internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescri
324324
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
325325
internal static void ApplyPolymorphismOptions(this JsonNode schema, JsonSchemaExporterContext context)
326326
{
327-
if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions)
327+
// The `context.Path.Length == 0` check is used to ensure that we only apply the polymorphism options
328+
// to the top-level schema and not to any nested schemas that are generated.
329+
if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions && context.Path.Length == 0)
328330
{
329331
var mappings = new JsonObject();
330332
foreach (var derivedType in polymorphismOptions.DerivedTypes)
331333
{
332-
if (derivedType.TypeDiscriminator is null)
334+
if (derivedType.TypeDiscriminator is { } discriminator)
333335
{
334-
continue;
336+
var jsonDerivedType = context.TypeInfo.Options.GetTypeInfo(derivedType.DerivedType);
337+
// Discriminator mappings are only supported in OpenAPI v3+ so we can safely assume that
338+
// the generated reference mappings will support the OpenAPI v3 schema reference format
339+
// that we hardcode here. We could use `OpenApiReference` to construct the reference and
340+
// serialize it but we use a hardcoded string here to avoid allocating a new object and
341+
// working around Microsoft.OpenApi's serialization libraries.
342+
mappings[$"{discriminator}"] = $"#/components/schemas/{context.TypeInfo.GetSchemaReferenceId()}{jsonDerivedType.GetSchemaReferenceId()}";
335343
}
336-
// TODO: Use the actual reference ID instead of the empty string.
337-
mappings[derivedType.TypeDiscriminator.ToString()!] = string.Empty;
338344
}
339345
schema[OpenApiSchemaKeywords.DiscriminatorKeyword] = polymorphismOptions.TypeDiscriminatorPropertyName;
340346
schema[OpenApiSchemaKeywords.DiscriminatorMappingKeyword] = mappings;

src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,47 +83,58 @@ internal sealed partial class OpenApiJsonSchema
8383
}
8484

8585
internal static IOpenApiAny? ReadOpenApiAny(ref Utf8JsonReader reader)
86+
=> ReadOpenApiAny(ref reader, out _);
87+
88+
internal static IOpenApiAny? ReadOpenApiAny(ref Utf8JsonReader reader, out string? type)
8689
{
90+
type = null;
8791
if (reader.TokenType == JsonTokenType.Null)
8892
{
8993
return new OpenApiNull();
9094
}
9195

9296
if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False)
9397
{
98+
type = "boolean";
9499
return new OpenApiBoolean(reader.GetBoolean());
95100
}
96101

97102
if (reader.TokenType == JsonTokenType.Number)
98103
{
99104
if (reader.TryGetInt32(out var intValue))
100105
{
106+
type = "integer";
101107
return new OpenApiInteger(intValue);
102108
}
103109

104110
if (reader.TryGetInt64(out var longValue))
105111
{
112+
type = "integer";
106113
return new OpenApiLong(longValue);
107114
}
108115

109116
if (reader.TryGetSingle(out var floatValue) && !float.IsInfinity(floatValue))
110117
{
118+
type = "number";
111119
return new OpenApiFloat(floatValue);
112120
}
113121

114122
if (reader.TryGetDouble(out var doubleValue))
115123
{
124+
type = "number";
116125
return new OpenApiDouble(doubleValue);
117126
}
118127
}
119128

120129
if (reader.TokenType == JsonTokenType.String)
121130
{
131+
type = "string";
122132
return new OpenApiString(reader.GetString());
123133
}
124134

125135
if (reader.TokenType == JsonTokenType.StartArray)
126136
{
137+
type = "array";
127138
var array = new OpenApiArray();
128139
while (reader.TokenType != JsonTokenType.EndArray)
129140
{
@@ -135,6 +146,7 @@ internal sealed partial class OpenApiJsonSchema
135146

136147
if (reader.TokenType == JsonTokenType.StartObject)
137148
{
149+
type = "object";
138150
var obj = new OpenApiObject();
139151
reader.Read();
140152
while (reader.TokenType != JsonTokenType.EndObject)
@@ -294,6 +306,13 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
294306
reader.Read();
295307
schema.Extensions.Add(OpenApiConstants.SchemaId, new OpenApiString(reader.GetString()));
296308
break;
309+
// OpenAPI does not support the `const` keyword in its schema implementation, so
310+
// we map it to its closest approximation, an enum with a single value, here.
311+
case OpenApiSchemaKeywords.ConstKeyword:
312+
reader.Read();
313+
schema.Enum = [ReadOpenApiAny(ref reader, out var constType)];
314+
schema.Type = constType;
315+
break;
297316
default:
298317
reader.Skip();
299318
break;

src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ internal class OpenApiSchemaKeywords
2525
public const string MaxItemsKeyword = "maxItems";
2626
public const string RefKeyword = "$ref";
2727
public const string SchemaIdKeyword = "x-schema-id";
28+
public const string ConstKeyword = "const";
2829
}

src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ internal sealed class OpenApiSchemaService(
9090
}
9191
schema.ApplyPrimitiveTypesAndFormats(context);
9292
schema.ApplySchemaReferenceId(context);
93+
schema.ApplyPolymorphismOptions(context);
9394
if (context.PropertyInfo is { AttributeProvider: { } attributeProvider } jsonPropertyInfo)
9495
{
9596
schema.ApplyNullabilityContextInfo(jsonPropertyInfo);

src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,15 @@ public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema)
9999
}
100100
if (schema.AnyOf is not null)
101101
{
102+
// AnyOf schemas in a polymorphic type should contain a reference to the parent schema
103+
// ID to support disambiguating between a derived type on its own and a derived type
104+
// as part of a polymorphic schema.
105+
var baseTypeSchemaId = schema.Extensions.TryGetValue(OpenApiConstants.SchemaId, out var schemaId)
106+
? ((OpenApiString)schemaId).Value
107+
: null;
102108
foreach (var anyOfSchema in schema.AnyOf)
103109
{
104-
AddOrUpdateSchemaByReference(anyOfSchema);
110+
AddOrUpdateSchemaByReference(anyOfSchema, baseTypeSchemaId);
105111
}
106112
}
107113
if (schema.Properties is not null)
@@ -113,8 +119,9 @@ public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema)
113119
}
114120
}
115121

116-
private void AddOrUpdateSchemaByReference(OpenApiSchema schema)
122+
private void AddOrUpdateSchemaByReference(OpenApiSchema schema, string? baseTypeSchemaId = null)
117123
{
124+
var targetReferenceId = baseTypeSchemaId is not null ? $"{baseTypeSchemaId}{GetSchemaReferenceId(schema)}" : GetSchemaReferenceId(schema);
118125
if (SchemasByReference.TryGetValue(schema, out var referenceId))
119126
{
120127
// If we've already used this reference ID else where in the document, increment a counter value to the reference
@@ -144,7 +151,7 @@ private void AddOrUpdateSchemaByReference(OpenApiSchema schema)
144151
// }
145152
// In this case, although the reference ID based on the .NET type we would use is `string`, the
146153
// two schemas are distinct.
147-
if (referenceId == null && GetSchemaReferenceId(schema) is { } targetReferenceId)
154+
if (referenceId == null && targetReferenceId is not null)
148155
{
149156
if (_referenceIdCounter.TryGetValue(targetReferenceId, out var counter))
150157
{
@@ -161,7 +168,7 @@ private void AddOrUpdateSchemaByReference(OpenApiSchema schema)
161168
}
162169
else
163170
{
164-
SchemasByReference[schema] = null;
171+
SchemasByReference[schema] = baseTypeSchemaId is not null ? targetReferenceId : null;
165172
}
166173
}
167174

src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,20 @@
5959
"content": {
6060
"application/json": {
6161
"schema": {
62-
"type": "object"
62+
"type": "object",
63+
"properties": {
64+
"hypotenuse": {
65+
"type": "number",
66+
"format": "double"
67+
},
68+
"color": {
69+
"type": "string"
70+
},
71+
"sides": {
72+
"type": "integer",
73+
"format": "int32"
74+
}
75+
}
6376
}
6477
}
6578
}
@@ -78,7 +91,25 @@
7891
"content": {
7992
"application/json": {
8093
"schema": {
81-
"type": "object"
94+
"required": [
95+
"$type"
96+
],
97+
"type": "object",
98+
"anyOf": [
99+
{
100+
"$ref": "#/components/schemas/ShapeTriangle"
101+
},
102+
{
103+
"$ref": "#/components/schemas/ShapeSquare"
104+
}
105+
],
106+
"discriminator": {
107+
"propertyName": "$type",
108+
"mapping": {
109+
"triangle": "#/components/schemas/ShapeTriangle",
110+
"square": "#/components/schemas/ShapeSquare"
111+
}
112+
}
82113
}
83114
}
84115
}
@@ -89,6 +120,48 @@
89120
},
90121
"components": {
91122
"schemas": {
123+
"ShapeSquare": {
124+
"properties": {
125+
"$type": {
126+
"enum": [
127+
"square"
128+
],
129+
"type": "string"
130+
},
131+
"area": {
132+
"type": "number",
133+
"format": "double"
134+
},
135+
"color": {
136+
"type": "string"
137+
},
138+
"sides": {
139+
"type": "integer",
140+
"format": "int32"
141+
}
142+
}
143+
},
144+
"ShapeTriangle": {
145+
"properties": {
146+
"$type": {
147+
"enum": [
148+
"triangle"
149+
],
150+
"type": "string"
151+
},
152+
"hypotenuse": {
153+
"type": "number",
154+
"format": "double"
155+
},
156+
"color": {
157+
"type": "string"
158+
},
159+
"sides": {
160+
"type": "integer",
161+
"format": "int32"
162+
}
163+
}
164+
},
92165
"Todo": {
93166
"required": [
94167
"id",

0 commit comments

Comments
 (0)