Skip to content

Commit 257d690

Browse files
authored
Use BaseTypeInfo API to fix up polymorphic schemas (#56908)
* Use BaseTypeInfo API to fix up polymorphic schemas * Change schema for polymorphic types with non-abstract base class * Add comments, update API name, and add more tests * More feedback and tests
1 parent abc946f commit 257d690

File tree

8 files changed

+364
-30
lines changed

8 files changed

+364
-30
lines changed

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,17 +334,27 @@ static bool SupportsNullableProperty(BindingSource bindingSource) =>bindingSourc
334334
}
335335

336336
/// <summary>
337-
/// Applies the polymorphism options to the target schema following OpenAPI v3's conventions.
337+
/// Applies the polymorphism options defined by System.Text.Json to the target schema following OpenAPI v3's
338+
/// conventions for the discriminator property.
338339
/// </summary>
339340
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
340341
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
341342
/// <param name="createSchemaReferenceId">A delegate that generates the reference ID to create for a type.</param>
342-
internal static void ApplyPolymorphismOptions(this JsonNode schema, JsonSchemaExporterContext context, Func<JsonTypeInfo, string?> createSchemaReferenceId)
343+
internal static void MapPolymorphismOptionsToDiscriminator(this JsonNode schema, JsonSchemaExporterContext context, Func<JsonTypeInfo, string?> createSchemaReferenceId)
343344
{
344-
// The `context.Path.Length == 0` check is used to ensure that we only apply the polymorphism options
345+
// The `context.BaseTypeInfo == null` check is used to ensure that we only apply the polymorphism options
345346
// to the top-level schema and not to any nested schemas that are generated.
346-
if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions && context.Path.Length == 0)
347+
if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions && context.BaseTypeInfo == null)
347348
{
349+
// System.Text.Json supports serializing to a non-abstract base class if no discriminator is provided.
350+
// OpenAPI requires that all polymorphic sub-schemas have an associated discriminator. If the base type
351+
// doesn't declare itself as its own derived type via [JsonDerived], then it can't have a discriminator,
352+
// which OpenAPI requires. In that case, we exit early to avoid mapping the polymorphism options
353+
// to the `discriminator` property and return an un-discriminated `anyOf` schema instead.
354+
if (IsNonAbstractTypeWithoutDerivedTypeReference(context))
355+
{
356+
return;
357+
}
348358
var mappings = new JsonObject();
349359
foreach (var derivedType in polymorphismOptions.DerivedTypes)
350360
{
@@ -376,6 +386,24 @@ internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExpo
376386
{
377387
schema[OpenApiConstants.SchemaId] = schemaReferenceId;
378388
}
389+
// If the type is a non-abstract base class that is not one of the derived types then mark it as a base schema.
390+
if (context.BaseTypeInfo == context.TypeInfo &&
391+
IsNonAbstractTypeWithoutDerivedTypeReference(context))
392+
{
393+
schema[OpenApiConstants.SchemaId] = "Base";
394+
}
395+
}
396+
397+
/// <summary>
398+
/// Returns <langword ref="true" /> if the current type is a non-abstract base class that is not defined as its
399+
/// own derived type.
400+
/// </summary>
401+
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
402+
private static bool IsNonAbstractTypeWithoutDerivedTypeReference(JsonSchemaExporterContext context)
403+
{
404+
return !context.TypeInfo.Type.IsAbstract
405+
&& context.TypeInfo.PolymorphismOptions is { } polymorphismOptions
406+
&& !polymorphismOptions.DerivedTypes.Any(type => type.DerivedType == context.TypeInfo.Type);
379407
}
380408

381409
/// <summary>

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,10 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
300300
case OpenApiSchemaKeywords.DiscriminatorMappingKeyword:
301301
reader.Read();
302302
var mappings = ReadDictionary<string>(ref reader);
303-
schema.Discriminator.Mapping = mappings;
303+
if (mappings is not null)
304+
{
305+
schema.Discriminator.Mapping = mappings;
306+
}
304307
break;
305308
case OpenApiConstants.SchemaId:
306309
reader.Read();

src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,5 @@ internal class OpenApiSchemaKeywords
2424
public const string MinItemsKeyword = "minItems";
2525
public const string MaxItemsKeyword = "maxItems";
2626
public const string RefKeyword = "$ref";
27-
public const string SchemaIdKeyword = "x-schema-id";
2827
public const string ConstKeyword = "const";
2928
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ internal sealed class OpenApiSchemaService(
9393
var createSchemaReferenceId = optionsMonitor.Get(documentName).CreateSchemaReferenceId;
9494
schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId);
9595
schema.ApplySchemaReferenceId(context, createSchemaReferenceId);
96-
schema.ApplyPolymorphismOptions(context, createSchemaReferenceId);
96+
schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId);
9797
if (context.PropertyInfo is { } jsonPropertyInfo)
9898
{
9999
// Short-circuit STJ's handling of nested properties, which uses a reference to the

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema, bool captureS
8484
// Only capture top-level schemas by ref. Nested schemas will follow the
8585
// reference by duplicate rules.
8686
AddOrUpdateSchemaByReference(schema, captureSchemaByRef: captureSchemaByRef);
87+
AddOrUpdateAnyOfSubSchemaByReference(schema);
8788
if (schema.AdditionalProperties is not null)
8889
{
8990
AddOrUpdateSchemaByReference(schema.AdditionalProperties);
@@ -99,24 +100,56 @@ public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema, bool captureS
99100
AddOrUpdateSchemaByReference(allOfSchema);
100101
}
101102
}
103+
if (schema.Properties is not null)
104+
{
105+
foreach (var property in schema.Properties.Values)
106+
{
107+
AddOrUpdateSchemaByReference(property);
108+
}
109+
}
110+
}
111+
112+
private void AddOrUpdateAnyOfSubSchemaByReference(OpenApiSchema schema)
113+
{
102114
if (schema.AnyOf is not null)
103115
{
104116
// AnyOf schemas in a polymorphic type should contain a reference to the parent schema
105117
// ID to support disambiguating between a derived type on its own and a derived type
106118
// as part of a polymorphic schema.
107-
var baseTypeSchemaId = schema.Annotations is not null && schema.Annotations.TryGetValue(OpenApiConstants.SchemaId, out var schemaId) ? schemaId?.ToString() : null;
119+
var baseTypeSchemaId = schema.Annotations is not null && schema.Annotations.TryGetValue(OpenApiConstants.SchemaId, out var schemaId)
120+
? schemaId?.ToString()
121+
: null;
108122
foreach (var anyOfSchema in schema.AnyOf)
109123
{
110124
AddOrUpdateSchemaByReference(anyOfSchema, baseTypeSchemaId);
111125
}
112126
}
113-
if (schema.Properties is not null)
127+
128+
if (schema.Items is not null)
129+
{
130+
AddOrUpdateAnyOfSubSchemaByReference(schema.Items);
131+
}
132+
133+
if (schema.Properties is { Count: > 0 })
114134
{
115135
foreach (var property in schema.Properties.Values)
116136
{
117-
AddOrUpdateSchemaByReference(property);
137+
AddOrUpdateAnyOfSubSchemaByReference(property);
138+
}
139+
}
140+
141+
if (schema.AllOf is not null)
142+
{
143+
foreach (var allOfSchema in schema.AllOf)
144+
{
145+
AddOrUpdateAnyOfSubSchemaByReference(allOfSchema);
118146
}
119147
}
148+
149+
if (schema.AdditionalProperties is not null)
150+
{
151+
AddOrUpdateAnyOfSubSchemaByReference(schema.AdditionalProperties);
152+
}
120153
}
121154

122155
private void AddOrUpdateSchemaByReference(OpenApiSchema schema, string? baseTypeSchemaId = null, bool captureSchemaByRef = false)

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PolymorphicSchemas.cs

Lines changed: 141 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,53 @@ await VerifyOpenApiDocument(builder, document =>
129129
}
130130

131131
[Fact]
132-
public async Task HandlesPolymorphicTypesWithNonAbstractBaseClass()
132+
public async Task HandlesPolymorphicTypesWithNonAbstractBaseClassWithNoDiscriminator()
133133
{
134134
// Arrange
135135
var builder = CreateBuilder();
136136

137137
// Act
138138
builder.MapPost("/api", (Color color) => { });
139139

140+
// Assert
141+
await VerifyOpenApiDocument(builder, document =>
142+
{
143+
var operation = document.Paths["/api"].Operations[OperationType.Post];
144+
Assert.NotNull(operation.RequestBody);
145+
var requestBody = operation.RequestBody.Content;
146+
Assert.True(requestBody.TryGetValue("application/json", out var mediaType));
147+
var schema = mediaType.Schema.GetEffective(document);
148+
// Assert discriminator mappings are not configured for this type since we
149+
// can't meet OpenAPI's restrictions that derived types _always_ have a discriminator
150+
// property associated with them.
151+
Assert.Null(schema.Discriminator);
152+
Assert.Collection(schema.AnyOf,
153+
schema => Assert.Equal("ColorPaintColor", schema.Reference.Id),
154+
schema => Assert.Equal("ColorFabricColor", schema.Reference.Id),
155+
schema => Assert.Equal("ColorBase", schema.Reference.Id));
156+
// Assert schema with discriminator = "paint" has been inserted into the components
157+
Assert.True(document.Components.Schemas.TryGetValue("ColorPaintColor", out var paintSchema));
158+
Assert.Contains("$type", paintSchema.Properties.Keys);
159+
Assert.Equal("paint", ((OpenApiString)paintSchema.Properties["$type"].Enum.First()).Value);
160+
// Assert schema with discriminator = "fabric" has been inserted into the components
161+
Assert.True(document.Components.Schemas.TryGetValue("ColorFabricColor", out var fabricSchema));
162+
Assert.Contains("$type", fabricSchema.Properties.Keys);
163+
Assert.Equal("fabric", ((OpenApiString)fabricSchema.Properties["$type"].Enum.First()).Value);
164+
// Assert that schema for `Color` has been inserted into the components without a discriminator
165+
Assert.True(document.Components.Schemas.TryGetValue("ColorBase", out var colorSchema));
166+
Assert.DoesNotContain("$type", colorSchema.Properties.Keys);
167+
});
168+
}
169+
170+
[Fact]
171+
public async Task HandlesPolymorphicTypesWithNonAbstractBaseClassAndDiscriminator()
172+
{
173+
// Arrange
174+
var builder = CreateBuilder();
175+
176+
// Act
177+
builder.MapPost("/api", (Pet pet) => { });
178+
140179
// Assert
141180
await VerifyOpenApiDocument(builder, document =>
142181
{
@@ -148,30 +187,113 @@ await VerifyOpenApiDocument(builder, document =>
148187
// Assert discriminator mappings have been configured correctly
149188
Assert.Equal("$type", schema.Discriminator.PropertyName);
150189
Assert.Collection(schema.Discriminator.Mapping,
151-
item => Assert.Equal("paint", item.Key),
152-
item => Assert.Equal("fabric", item.Key)
190+
item => Assert.Equal("cat", item.Key),
191+
item => Assert.Equal("dog", item.Key),
192+
item => Assert.Equal("pet", item.Key)
153193
);
154194
Assert.Collection(schema.Discriminator.Mapping,
155-
item => Assert.Equal("#/components/schemas/ColorPaintColor", item.Value),
156-
item => Assert.Equal("#/components/schemas/ColorFabricColor", item.Value)
195+
item => Assert.Equal("#/components/schemas/PetCat", item.Value),
196+
item => Assert.Equal("#/components/schemas/PetDog", item.Value),
197+
item => Assert.Equal("#/components/schemas/PetPet", item.Value)
157198
);
158-
// Note that our implementation diverges from the OpenAPI specification here. OpenAPI
159-
// requires that derived types in a polymorphic schema _always_ have a discriminator
199+
// OpenAPI requires that derived types in a polymorphic schema _always_ have a discriminator
160200
// property associated with them. STJ permits the discriminator to be omitted from the
161201
// if the base type is a non-abstract class and falls back to serializing to this base
162-
// type. This is a known limitation of the current implementation.
202+
// type. In this scenario, we check that the base class is not included in the `anyOf`
203+
// schema.
163204
Assert.Collection(schema.AnyOf,
164-
schema => Assert.Equal("ColorPaintColor", schema.Reference.Id),
165-
schema => Assert.Equal("ColorFabricColor", schema.Reference.Id),
166-
schema => Assert.Equal("ColorColor", schema.Reference.Id));
167-
// Assert schema with discriminator = "paint" has been inserted into the components
168-
Assert.True(document.Components.Schemas.TryGetValue("ColorPaintColor", out var paintSchema));
169-
Assert.Contains(schema.Discriminator.PropertyName, paintSchema.Properties.Keys);
170-
Assert.Equal("paint", ((OpenApiString)paintSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
171-
// Assert schema with discriminator = "fabric" has been inserted into the components
172-
Assert.True(document.Components.Schemas.TryGetValue("ColorFabricColor", out var fabricSchema));
173-
Assert.Contains(schema.Discriminator.PropertyName, fabricSchema.Properties.Keys);
174-
Assert.Equal("fabric", ((OpenApiString)fabricSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
205+
schema => Assert.Equal("PetCat", schema.Reference.Id),
206+
schema => Assert.Equal("PetDog", schema.Reference.Id),
207+
schema => Assert.Equal("PetPet", schema.Reference.Id));
208+
// Assert schema with discriminator = "dog" has been inserted into the components
209+
Assert.True(document.Components.Schemas.TryGetValue("PetDog", out var dogSchema));
210+
Assert.Contains(schema.Discriminator.PropertyName, dogSchema.Properties.Keys);
211+
Assert.Equal("dog", ((OpenApiString)dogSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
212+
// Assert schema with discriminator = "cat" has been inserted into the components
213+
Assert.True(document.Components.Schemas.TryGetValue("PetCat", out var catSchema));
214+
Assert.Contains(schema.Discriminator.PropertyName, catSchema.Properties.Keys);
215+
Assert.Equal("cat", ((OpenApiString)catSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
216+
// Assert schema with discriminator = "cat" has been inserted into the components
217+
Assert.True(document.Components.Schemas.TryGetValue("PetPet", out var petSchema));
218+
Assert.Contains(schema.Discriminator.PropertyName, petSchema.Properties.Keys);
219+
Assert.Equal("pet", ((OpenApiString)petSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
220+
});
221+
}
222+
223+
[Fact]
224+
public async Task HandlesPolymorphicTypesWithNoExplicitDiscriminators()
225+
{
226+
// Arrange
227+
var builder = CreateBuilder();
228+
229+
// Act
230+
builder.MapPost("/api", (Organism color) => { });
231+
232+
// Assert
233+
await VerifyOpenApiDocument(builder, document =>
234+
{
235+
var operation = document.Paths["/api"].Operations[OperationType.Post];
236+
Assert.NotNull(operation.RequestBody);
237+
var requestBody = operation.RequestBody.Content;
238+
Assert.True(requestBody.TryGetValue("application/json", out var mediaType));
239+
var schema = mediaType.Schema.GetEffective(document);
240+
// Assert discriminator mappings are not configured for this type since we
241+
// can't meet OpenAPI's restrictions that derived types _always_ have a discriminator
242+
// property associated with them.
243+
Assert.Null(schema.Discriminator);
244+
Assert.Collection(schema.AnyOf,
245+
schema => Assert.Equal("OrganismAnimal", schema.Reference.Id),
246+
schema => Assert.Equal("OrganismPlant", schema.Reference.Id),
247+
schema => Assert.Equal("OrganismBase", schema.Reference.Id));
248+
// Assert that schemas without discriminators have been inserted into the components
249+
Assert.True(document.Components.Schemas.TryGetValue("OrganismAnimal", out var animalSchema));
250+
Assert.DoesNotContain("$type", animalSchema.Properties.Keys);
251+
Assert.True(document.Components.Schemas.TryGetValue("OrganismPlant", out var plantSchema));
252+
Assert.DoesNotContain("$type", plantSchema.Properties.Keys);
253+
Assert.True(document.Components.Schemas.TryGetValue("OrganismBase", out var baseSchema));
254+
Assert.DoesNotContain("$type", baseSchema.Properties.Keys);
255+
});
256+
}
257+
258+
[Fact]
259+
public async Task HandlesPolymorphicTypesWithSelfReference()
260+
{
261+
// Arrange
262+
var builder = CreateBuilder();
263+
264+
// Act
265+
builder.MapPost("/api", (Employee color) => { });
266+
267+
// Assert
268+
await VerifyOpenApiDocument(builder, document =>
269+
{
270+
var operation = document.Paths["/api"].Operations[OperationType.Post];
271+
Assert.NotNull(operation.RequestBody);
272+
var requestBody = operation.RequestBody.Content;
273+
Assert.True(requestBody.TryGetValue("application/json", out var mediaType));
274+
Assert.Equal("Employee", mediaType.Schema.Reference.Id);
275+
var schema = mediaType.Schema.GetEffective(document);
276+
// Assert that discriminator mappings are configured correctly for type.
277+
Assert.Equal("$type", schema.Discriminator.PropertyName);
278+
Assert.Collection(schema.Discriminator.Mapping,
279+
item => Assert.Equal("manager", item.Key),
280+
item => Assert.Equal("employee", item.Key)
281+
);
282+
Assert.Collection(schema.Discriminator.Mapping,
283+
item => Assert.Equal("#/components/schemas/EmployeeManager", item.Value),
284+
item => Assert.Equal("#/components/schemas/EmployeeEmployee", item.Value)
285+
);
286+
// Assert that anyOf schemas use the correct reference IDs.
287+
Assert.Collection(schema.AnyOf,
288+
schema => Assert.Equal("EmployeeManager", schema.Reference.Id),
289+
schema => Assert.Equal("EmployeeEmployee", schema.Reference.Id));
290+
// Assert that schemas without discriminators have been inserted into the components
291+
Assert.True(document.Components.Schemas.TryGetValue("EmployeeManager", out var managerSchema));
292+
Assert.Equal("manager", ((OpenApiString)managerSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
293+
Assert.True(document.Components.Schemas.TryGetValue("EmployeeEmployee", out var employeeSchema));
294+
Assert.Equal("employee", ((OpenApiString)employeeSchema.Properties[schema.Discriminator.PropertyName].Enum.First()).Value);
295+
// Assert that the schema has a correct self-reference to the base-type. This points to the schema that contains the discriminator.
296+
Assert.Equal("Employee", employeeSchema.Properties["manager"].Reference.Id);
175297
});
176298
}
177299
}

0 commit comments

Comments
 (0)