Skip to content

Commit 5f9c92a

Browse files
committed
Always ref request body and response schemas and fix nested types
1 parent ecdc518 commit 5f9c92a

15 files changed

+320
-227
lines changed

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ internal static class JsonTypeInfoExtensions
5353
internal static string? GetSchemaReferenceId(this JsonTypeInfo jsonTypeInfo, bool isTopLevel = true)
5454
{
5555
var type = jsonTypeInfo.Type;
56-
if (isTopLevel && OpenApiConstants.PrimitiveTypes.Contains(type))
56+
var underlyingType = Nullable.GetUnderlyingType(type);
57+
if (isTopLevel && OpenApiConstants.PrimitiveTypes.Contains(underlyingType ?? type))
5758
{
5859
return null;
5960
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
313313
schema.Enum = [ReadOpenApiAny(ref reader, out var constType)];
314314
schema.Type = constType;
315315
break;
316+
case OpenApiSchemaKeywords.RefKeyword:
317+
reader.Read();
318+
schema.Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = reader.GetString() };
319+
break;
316320
default:
317321
reader.Skip();
318322
break;

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ private async Task<OpenApiResponse> GetResponseAsync(ApiDescription apiDescripti
228228
.Select(responseFormat => responseFormat.MediaType);
229229
foreach (var contentType in apiResponseFormatContentTypes)
230230
{
231-
var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(type, null, cancellationToken) : new OpenApiSchema();
231+
var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(type, null, cancellationToken, captureSchemaByRef: true) : new OpenApiSchema();
232232
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
233233
}
234234

@@ -465,7 +465,7 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(IList<ApiRequestFormat
465465
foreach (var requestForm in supportedRequestFormats)
466466
{
467467
var contentType = requestForm.MediaType;
468-
requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(bodyParameter.Type, bodyParameter, cancellationToken) };
468+
requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(bodyParameter.Type, bodyParameter, cancellationToken, captureSchemaByRef: true) };
469469
}
470470

471471
return requestBody;

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ internal sealed class OpenApiSchemaService(
9393
schema.ApplyPolymorphismOptions(context);
9494
if (context.PropertyInfo is { AttributeProvider: { } attributeProvider } jsonPropertyInfo)
9595
{
96+
// Short-circuit STJ's handling of nested properties, which uses a reference to the
97+
// properties type schema with a schema that uses a document level reference.
98+
// For example, if the property is a `public NestedTyped Nested { get; set; }` property,
99+
// "nested": "#/properties/nested" because "nested": "#/components/schemas/NestedType"
100+
if (jsonPropertyInfo.PropertyType == jsonPropertyInfo.DeclaringType)
101+
{
102+
return new JsonObject { [OpenApiSchemaKeywords.RefKeyword] = context.TypeInfo.GetSchemaReferenceId() };
103+
}
96104
schema.ApplyNullabilityContextInfo(jsonPropertyInfo);
97105
if (attributeProvider.GetCustomAttributes(inherit: false).OfType<ValidationAttribute>() is { } validationAttributes)
98106
{
@@ -112,7 +120,7 @@ internal sealed class OpenApiSchemaService(
112120
}
113121
};
114122

115-
internal async Task<OpenApiSchema> GetOrCreateSchemaAsync(Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default)
123+
internal async Task<OpenApiSchema> GetOrCreateSchemaAsync(Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default, bool captureSchemaByRef = false)
116124
{
117125
var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription
118126
&& parameterDescription.ModelMetadata.PropertyName is null
@@ -126,7 +134,7 @@ internal async Task<OpenApiSchema> GetOrCreateSchemaAsync(Type type, ApiParamete
126134
Debug.Assert(deserializedSchema != null, "The schema should have been deserialized successfully and materialize a non-null value.");
127135
var schema = deserializedSchema.Schema;
128136
await ApplySchemaTransformersAsync(schema, type, parameterDescription, cancellationToken);
129-
_schemaStore.PopulateSchemaIntoReferenceCache(schema);
137+
_schemaStore.PopulateSchemaIntoReferenceCache(schema, captureSchemaByRef);
130138
return schema;
131139
}
132140

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,12 @@ public JsonNode GetOrAdd(OpenApiSchemaKey key, Func<OpenApiSchemaKey, JsonNode>
7979
/// schemas into the top-level document.
8080
/// </summary>
8181
/// <param name="schema">The <see cref="OpenApiSchema"/> to add to the schemas-with-references cache.</param>
82-
public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema)
82+
/// <param name="captureSchemaByRef"><see langword="true"/> if schema should always be referenced instead of inlined.</param>
83+
public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema, bool captureSchemaByRef)
8384
{
84-
AddOrUpdateSchemaByReference(schema);
85+
// Only capture top-level schemas by ref. Nested schemas will follow the
86+
// reference by duplicate rules.
87+
AddOrUpdateSchemaByReference(schema, captureSchemaByRef: captureSchemaByRef);
8588
if (schema.AdditionalProperties is not null)
8689
{
8790
AddOrUpdateSchemaByReference(schema.AdditionalProperties);
@@ -119,10 +122,10 @@ public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema)
119122
}
120123
}
121124

122-
private void AddOrUpdateSchemaByReference(OpenApiSchema schema, string? baseTypeSchemaId = null)
125+
private void AddOrUpdateSchemaByReference(OpenApiSchema schema, string? baseTypeSchemaId = null, bool captureSchemaByRef = false)
123126
{
124127
var targetReferenceId = baseTypeSchemaId is not null ? $"{baseTypeSchemaId}{GetSchemaReferenceId(schema)}" : GetSchemaReferenceId(schema);
125-
if (SchemasByReference.TryGetValue(schema, out var referenceId))
128+
if (SchemasByReference.TryGetValue(schema, out var referenceId) || captureSchemaByRef)
126129
{
127130
// If we've already used this reference ID else where in the document, increment a counter value to the reference
128131
// ID to avoid name collisions. These collisions are most likely to occur when the same .NET type produces a different

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

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,7 @@
5959
"content": {
6060
"application/json": {
6161
"schema": {
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-
}
62+
"$ref": "#/components/schemas/Triangle"
7663
}
7764
}
7865
}
@@ -91,25 +78,7 @@
9178
"content": {
9279
"application/json": {
9380
"schema": {
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-
}
81+
"$ref": "#/components/schemas/Shape"
11382
}
11483
}
11584
}
@@ -120,6 +89,27 @@
12089
},
12190
"components": {
12291
"schemas": {
92+
"Shape": {
93+
"required": [
94+
"$type"
95+
],
96+
"type": "object",
97+
"anyOf": [
98+
{
99+
"$ref": "#/components/schemas/ShapeTriangle"
100+
},
101+
{
102+
"$ref": "#/components/schemas/ShapeSquare"
103+
}
104+
],
105+
"discriminator": {
106+
"propertyName": "$type",
107+
"mapping": {
108+
"triangle": "#/components/schemas/ShapeTriangle",
109+
"square": "#/components/schemas/ShapeSquare"
110+
}
111+
}
112+
},
123113
"ShapeSquare": {
124114
"properties": {
125115
"$type": {
@@ -186,6 +176,22 @@
186176
"format": "date-time"
187177
}
188178
}
179+
},
180+
"Triangle": {
181+
"type": "object",
182+
"properties": {
183+
"hypotenuse": {
184+
"type": "number",
185+
"format": "double"
186+
},
187+
"color": {
188+
"type": "string"
189+
},
190+
"sides": {
191+
"type": "integer",
192+
"format": "int32"
193+
}
194+
}
189195
}
190196
}
191197
},

src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt

Lines changed: 70 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -303,25 +303,7 @@
303303
"content": {
304304
"application/json": {
305305
"schema": {
306-
"required": [
307-
"$type"
308-
],
309-
"type": "object",
310-
"anyOf": [
311-
{
312-
"$ref": "#/components/schemas/ShapeTriangle"
313-
},
314-
{
315-
"$ref": "#/components/schemas/ShapeSquare"
316-
}
317-
],
318-
"discriminator": {
319-
"propertyName": "$type",
320-
"mapping": {
321-
"triangle": "#/components/schemas/ShapeTriangle",
322-
"square": "#/components/schemas/ShapeSquare"
323-
}
324-
}
306+
"$ref": "#/components/schemas/Shape"
325307
}
326308
}
327309
},
@@ -343,29 +325,7 @@
343325
"content": {
344326
"application/json": {
345327
"schema": {
346-
"required": [
347-
"$type"
348-
],
349-
"type": "object",
350-
"anyOf": [
351-
{
352-
"$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity"
353-
},
354-
{
355-
"$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries"
356-
},
357-
{
358-
"$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews"
359-
}
360-
],
361-
"discriminator": {
362-
"propertyName": "$type",
363-
"mapping": {
364-
"0": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity",
365-
"1": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries",
366-
"2": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews"
367-
}
368-
}
328+
"$ref": "#/components/schemas/WeatherForecastBase"
369329
}
370330
}
371331
},
@@ -387,25 +347,7 @@
387347
"content": {
388348
"application/json": {
389349
"schema": {
390-
"required": [
391-
"discriminator"
392-
],
393-
"type": "object",
394-
"anyOf": [
395-
{
396-
"$ref": "#/components/schemas/PersonStudent"
397-
},
398-
{
399-
"$ref": "#/components/schemas/PersonTeacher"
400-
}
401-
],
402-
"discriminator": {
403-
"propertyName": "discriminator",
404-
"mapping": {
405-
"student": "#/components/schemas/PersonStudent",
406-
"teacher": "#/components/schemas/PersonTeacher"
407-
}
408-
}
350+
"$ref": "#/components/schemas/Person"
409351
}
410352
}
411353
},
@@ -447,6 +389,27 @@
447389
"format": "int32"
448390
}
449391
},
392+
"Person": {
393+
"required": [
394+
"discriminator"
395+
],
396+
"type": "object",
397+
"anyOf": [
398+
{
399+
"$ref": "#/components/schemas/PersonStudent"
400+
},
401+
{
402+
"$ref": "#/components/schemas/PersonTeacher"
403+
}
404+
],
405+
"discriminator": {
406+
"propertyName": "discriminator",
407+
"mapping": {
408+
"student": "#/components/schemas/PersonStudent",
409+
"teacher": "#/components/schemas/PersonTeacher"
410+
}
411+
}
412+
},
450413
"PersonStudent": {
451414
"properties": {
452415
"discriminator": {
@@ -489,6 +452,27 @@
489452
}
490453
}
491454
},
455+
"Shape": {
456+
"required": [
457+
"$type"
458+
],
459+
"type": "object",
460+
"anyOf": [
461+
{
462+
"$ref": "#/components/schemas/ShapeTriangle"
463+
},
464+
{
465+
"$ref": "#/components/schemas/ShapeSquare"
466+
}
467+
],
468+
"discriminator": {
469+
"propertyName": "$type",
470+
"mapping": {
471+
"triangle": "#/components/schemas/ShapeTriangle",
472+
"square": "#/components/schemas/ShapeSquare"
473+
}
474+
}
475+
},
492476
"ShapeSquare": {
493477
"properties": {
494478
"$type": {
@@ -547,6 +531,31 @@
547531
}
548532
}
549533
},
534+
"WeatherForecastBase": {
535+
"required": [
536+
"$type"
537+
],
538+
"type": "object",
539+
"anyOf": [
540+
{
541+
"$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity"
542+
},
543+
{
544+
"$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries"
545+
},
546+
{
547+
"$ref": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews"
548+
}
549+
],
550+
"discriminator": {
551+
"propertyName": "$type",
552+
"mapping": {
553+
"0": "#/components/schemas/WeatherForecastBaseWeatherForecastWithCity",
554+
"1": "#/components/schemas/WeatherForecastBaseWeatherForecastWithTimeSeries",
555+
"2": "#/components/schemas/WeatherForecastBaseWeatherForecastWithLocalNews"
556+
}
557+
}
558+
},
550559
"WeatherForecastBaseWeatherForecastWithCity": {
551560
"required": [
552561
"city"

0 commit comments

Comments
 (0)