Skip to content

Commit 13f3713

Browse files
authored
Always ref request body and response schemas and fix nested types (#56513)
* Always ref request body and response schemas and fix nested types * Address feedback
1 parent e6a0cc8 commit 13f3713

15 files changed

+324
-231
lines changed

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

+2-1
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

+4
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

+6-6
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, captureSchemaByRef: true, cancellationToken) : new OpenApiSchema();
232232
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
233233
}
234234

@@ -269,7 +269,7 @@ private async Task<OpenApiResponse> GetResponseAsync(ApiDescription apiDescripti
269269
_ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}")
270270
},
271271
Required = IsRequired(parameter),
272-
Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, parameter, cancellationToken),
272+
Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, parameter, cancellationToken: cancellationToken),
273273
Description = GetParameterDescriptionFromAttribute(parameter)
274274
};
275275

@@ -347,7 +347,7 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(IList<ApiRequestFormat
347347
if (parameter.All(parameter => parameter.ModelMetadata.ContainerType is null))
348348
{
349349
var description = parameter.Single();
350-
var parameterSchema = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken);
350+
var parameterSchema = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken: cancellationToken);
351351
// Form files are keyed by their parameter name so we must capture the parameter name
352352
// as a property in the schema.
353353
if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection))
@@ -410,15 +410,15 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(IList<ApiRequestFormat
410410
var propertySchema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
411411
foreach (var description in parameter)
412412
{
413-
propertySchema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken);
413+
propertySchema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken: cancellationToken);
414414
}
415415
schema.AllOf.Add(propertySchema);
416416
}
417417
else
418418
{
419419
foreach (var description in parameter)
420420
{
421-
schema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken);
421+
schema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken: cancellationToken);
422422
}
423423
}
424424
}
@@ -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, captureSchemaByRef: true, cancellationToken: cancellationToken) };
469469
}
470470

471471
return requestBody;

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

+10-2
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" becomes "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, bool captureSchemaByRef = false, CancellationToken cancellationToken = default)
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

+7-4
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

+39-33
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
},

0 commit comments

Comments
 (0)