Skip to content

Commit 10fe317

Browse files
[release/10.0] Fix circular reference resolution for schemas containing arrays (#64720)
* OpenAPI schemas: Add failing test for circular references with an array * Fix array circular references. The fix is done by registering components before going deep into the recursion tree (where the leaves would be registered first). Fixing this revealed an issue for default values for "local" attributes. Local attributes/parameter info should not apply to componetized schemas. * Fix build warnings * Fix missing negation * Cleanup some funky logic * Fix cases where the schemas will not be componetized because the x-schema-id is null or empty * Stop ResolveReferenceForSchema when the schema has already been resolved * Remove unused method `CreateReference` * Remove OpenApiSchemaKey as only the Type property was used * Remove unnecessary using * Fix punctuation in comment. * Refactor null check in AddOpenApiSchemaByReference * Fix schemaId to be null instead of string.Empty when a schema is not componetized * Remove x-ref-id metadata as it isn't used anymore (and gave undesired results) --------- Co-authored-by: Sjoerd van der Meer <sjoerd.vandermeer@xebia.com>
1 parent f0f7699 commit 10fe317

File tree

9 files changed

+158
-80
lines changed

9 files changed

+158
-80
lines changed

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.ComponentModel;
55
using System.ComponentModel.DataAnnotations;
6+
using System.Diagnostics.CodeAnalysis;
67
using System.Globalization;
78
using System.Linq;
89
using System.Reflection;
@@ -175,13 +176,17 @@ internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValu
175176
return;
176177
}
177178

179+
var schemaAttribute = schema.WillBeComponentized()
180+
? OpenApiConstants.RefDefaultAnnotation
181+
: OpenApiSchemaKeywords.DefaultKeyword;
182+
178183
if (defaultValue is null)
179184
{
180-
schema[OpenApiSchemaKeywords.DefaultKeyword] = null;
185+
schema[schemaAttribute] = null;
181186
}
182187
else
183188
{
184-
schema[OpenApiSchemaKeywords.DefaultKeyword] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo);
189+
schema[schemaAttribute] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo);
185190
}
186191
}
187192

@@ -429,6 +434,36 @@ internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExpo
429434
}
430435
}
431436

437+
/// <summary>
438+
/// Determines whether the specified JSON schema will be moved into the components section.
439+
/// </summary>
440+
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
441+
/// <returns><see langword="true"/> if the schema will be componentized; otherwise, <see langword="false"/>.</returns>
442+
internal static bool WillBeComponentized(this JsonNode schema)
443+
=> schema.WillBeComponentized(out _);
444+
445+
/// <summary>
446+
/// Determines whether the specified JSON schema node contains a componentized schema identifier.
447+
/// </summary>
448+
/// <param name="schema">The JSON schema node to inspect for a componentized schema identifier.</param>
449+
/// <param name="schemaId">When this method returns <see langword="true"/>, contains the schema identifier found in the node; otherwise,
450+
/// <see langword="null"/>.</param>
451+
/// <returns><see langword="true"/> if the schema will be componentized; otherwise, <see langword="false"/>.</returns>
452+
internal static bool WillBeComponentized(this JsonNode schema, [NotNullWhen(true)] out string? schemaId)
453+
{
454+
if (schema[OpenApiConstants.SchemaId] is JsonNode schemaIdNode
455+
&& schemaIdNode.GetValueKind() == JsonValueKind.String)
456+
{
457+
schemaId = schemaIdNode.GetValue<string>();
458+
if (!string.IsNullOrEmpty(schemaId))
459+
{
460+
return true;
461+
}
462+
}
463+
schemaId = null;
464+
return false;
465+
}
466+
432467
/// <summary>
433468
/// Returns <langword ref="true" /> if the current type is a non-abstract base class that is not defined as its
434469
/// own derived type.
@@ -458,7 +493,7 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope
458493
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
459494
}
460495
}
461-
if (schema[OpenApiConstants.SchemaId] is not null &&
496+
if (schema.WillBeComponentized() &&
462497
propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema())
463498
{
464499
schema[OpenApiConstants.NullableProperty] = true;
@@ -472,7 +507,7 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope
472507
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
473508
internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema)
474509
{
475-
if (schema[OpenApiConstants.SchemaId] is not null &&
510+
if (schema.WillBeComponentized() &&
476511
schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
477512
{
478513
for (var i = typeArray.Count - 1; i >= 0; i--)

src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,33 @@ internal static class OpenApiDocumentExtensions
1414
/// <param name="document">The <see cref="OpenApiDocument"/> to register the schema onto.</param>
1515
/// <param name="schemaId">The ID that serves as the key for the schema in the schema store.</param>
1616
/// <param name="schema">The <see cref="IOpenApiSchema" /> to register into the document.</param>
17-
/// <returns>An <see cref="IOpenApiSchema"/> with a reference to the stored schema.</returns>
18-
public static IOpenApiSchema AddOpenApiSchemaByReference(this OpenApiDocument document, string schemaId, IOpenApiSchema schema)
17+
/// <param name="schemaReference">An <see cref="IOpenApiSchema"/> with a reference to the stored schema.</param>
18+
/// <returns>Whether the schema was added or already existed.</returns>
19+
public static bool AddOpenApiSchemaByReference(this OpenApiDocument document, string schemaId, IOpenApiSchema schema, out OpenApiSchemaReference schemaReference)
1920
{
20-
document.Components ??= new();
21-
document.Components.Schemas ??= new Dictionary<string, IOpenApiSchema>();
22-
document.Components.Schemas[schemaId] = schema;
21+
// Make sure the document has a workspace,
22+
// AddComponent will add it to the workspace when adding the component.
2323
document.Workspace ??= new();
24-
var location = document.BaseUri + "/components/schemas/" + schemaId;
25-
document.Workspace.RegisterComponentForDocument(document, schema, location);
24+
// AddComponent will only add the schema if it doesn't already exist.
25+
var schemaAdded = document.AddComponent(schemaId, schema);
2626

2727
object? description = null;
2828
object? example = null;
29-
if (schema is OpenApiSchema actualSchema)
29+
object? defaultAnnotation = null;
30+
if (schema is OpenApiSchema { Metadata: not null } actualSchema)
3031
{
31-
actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefDescriptionAnnotation, out description);
32-
actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefExampleAnnotation, out example);
32+
actualSchema.Metadata.TryGetValue(OpenApiConstants.RefDescriptionAnnotation, out description);
33+
actualSchema.Metadata.TryGetValue(OpenApiConstants.RefExampleAnnotation, out example);
34+
actualSchema.Metadata.TryGetValue(OpenApiConstants.RefDefaultAnnotation, out defaultAnnotation);
3335
}
3436

35-
return new OpenApiSchemaReference(schemaId, document)
37+
schemaReference = new OpenApiSchemaReference(schemaId, document)
3638
{
3739
Description = description as string,
3840
Examples = example is JsonNode exampleJson ? [exampleJson] : null,
41+
Default = defaultAnnotation as JsonNode,
3942
};
43+
44+
return schemaAdded;
4045
}
4146
}

src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics.CodeAnalysis;
5+
46
namespace Microsoft.AspNetCore.OpenApi;
57

68
internal static class OpenApiSchemaExtensions
@@ -18,4 +20,21 @@ public static IOpenApiSchema CreateOneOfNullableWrapper(this IOpenApiSchema orig
1820
]
1921
};
2022
}
23+
24+
public static bool IsComponentizedSchema(this OpenApiSchema schema)
25+
=> schema.IsComponentizedSchema(out _);
26+
27+
public static bool IsComponentizedSchema(this OpenApiSchema schema, [NotNullWhen(true)] out string? schemaId)
28+
{
29+
if(schema.Metadata is not null
30+
&& schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var schemaIdAsObject)
31+
&& schemaIdAsObject is string schemaIdString
32+
&& !string.IsNullOrEmpty(schemaIdString))
33+
{
34+
schemaId = schemaIdString;
35+
return true;
36+
}
37+
schemaId = null;
38+
return false;
39+
}
2140
}

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,17 +345,16 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
345345
schema.Enum = ReadJsonNode(ref reader, out var constType) is { } jsonNode ? [jsonNode] : [];
346346
schema.Type = constType;
347347
break;
348-
case OpenApiSchemaKeywords.RefKeyword:
348+
case OpenApiConstants.RefDescriptionAnnotation:
349349
reader.Read();
350350
schema.Metadata ??= new Dictionary<string, object>();
351-
schema.Metadata[OpenApiConstants.RefId] = reader.GetString() ?? string.Empty;
351+
schema.Metadata[OpenApiConstants.RefDescriptionAnnotation] = reader.GetString() ?? string.Empty;
352352
break;
353-
case OpenApiConstants.RefDescriptionAnnotation:
353+
case OpenApiConstants.RefDefaultAnnotation:
354354
reader.Read();
355355
schema.Metadata ??= new Dictionary<string, object>();
356-
schema.Metadata[OpenApiConstants.RefDescriptionAnnotation] = reader.GetString() ?? string.Empty;
356+
schema.Metadata[OpenApiConstants.RefDefaultAnnotation] = ReadJsonNode(ref reader)!;
357357
break;
358-
359358
default:
360359
reader.Skip();
361360
break;

src/OpenApi/src/Services/OpenApiConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal static class OpenApiConstants
1212
internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json";
1313
internal const string DescriptionId = "x-aspnetcore-id";
1414
internal const string SchemaId = "x-schema-id";
15-
internal const string RefId = "x-ref-id";
15+
internal const string RefDefaultAnnotation = "x-ref-default";
1616
internal const string RefDescriptionAnnotation = "x-ref-description";
1717
internal const string RefExampleAnnotation = "x-ref-example";
1818
internal const string RefKeyword = "$ref";

src/OpenApi/src/Services/Schemas/OpenApiSchemaKey.cs

Lines changed: 0 additions & 12 deletions
This file was deleted.

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

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
using Microsoft.AspNetCore.Http;
1515
using Microsoft.AspNetCore.Http.Json;
1616
using Microsoft.AspNetCore.Mvc.ApiExplorer;
17-
using Microsoft.AspNetCore.Mvc.Infrastructure;
1817
using Microsoft.Extensions.DependencyInjection;
1918
using Microsoft.Extensions.Options;
2019

@@ -116,7 +115,7 @@ internal sealed class OpenApiSchemaService(
116115
{
117116
schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo);
118117
}
119-
var isInlinedSchema = schema[OpenApiConstants.SchemaId] is null;
118+
var isInlinedSchema = !schema.WillBeComponentized();
120119
if (isInlinedSchema)
121120
{
122121
if (propertyAttributes.OfType<DescriptionAttribute>().LastOrDefault() is { } descriptionAttribute)
@@ -229,10 +228,7 @@ static JsonArray JsonArray(ReadOnlySpan<JsonNode> values)
229228

230229
internal async Task<OpenApiSchema> GetOrCreateUnresolvedSchemaAsync(OpenApiDocument? document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default)
231230
{
232-
var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription
233-
&& parameterDescription.ModelMetadata.PropertyName is null
234-
? new OpenApiSchemaKey(type, parameterInfoDescription.ParameterInfo) : new OpenApiSchemaKey(type, null);
235-
var schemaAsJsonObject = CreateSchema(key);
231+
var schemaAsJsonObject = CreateSchema(type);
236232
if (parameterDescription is not null)
237233
{
238234
schemaAsJsonObject.ApplyParameterInfo(parameterDescription, _jsonSerializerOptions.GetTypeInfo(type));
@@ -265,18 +261,33 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen
265261
{
266262
var schema = UnwrapOpenApiSchema(inputSchema);
267263

268-
if (schema.Metadata is not null &&
269-
schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var resolvedBaseSchemaId))
264+
var isComponentizedSchema = schema.IsComponentizedSchema(out var schemaId);
265+
266+
// When we register it, this will be the resulting reference
267+
OpenApiSchemaReference? resultSchemaReference = null;
268+
if (inputSchema is OpenApiSchema && isComponentizedSchema)
270269
{
271-
if (schema.AnyOf is { Count: > 0 })
270+
var targetReferenceId = baseSchemaId is not null
271+
? $"{baseSchemaId}{schemaId}"
272+
: schemaId;
273+
if (!string.IsNullOrEmpty(targetReferenceId))
272274
{
273-
for (var i = 0; i < schema.AnyOf.Count; i++)
275+
if (!document.AddOpenApiSchemaByReference(targetReferenceId, schema, out resultSchemaReference))
274276
{
275-
schema.AnyOf[i] = ResolveReferenceForSchema(document, schema.AnyOf[i], rootSchemaId, resolvedBaseSchemaId?.ToString());
277+
// We already added this schema, so it has already been resolved.
278+
return resultSchemaReference;
276279
}
277280
}
278281
}
279282

283+
if (schema.AnyOf is { Count: > 0 })
284+
{
285+
for (var i = 0; i < schema.AnyOf.Count; i++)
286+
{
287+
schema.AnyOf[i] = ResolveReferenceForSchema(document, schema.AnyOf[i], rootSchemaId, schemaId);
288+
}
289+
}
290+
280291
if (schema.Properties is not null)
281292
{
282293
foreach (var property in schema.Properties)
@@ -326,39 +337,9 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen
326337
schema.Not = ResolveReferenceForSchema(document, schema.Not, rootSchemaId);
327338
}
328339

329-
// Handle schemas where the references have been inlined by the JsonSchemaExporter. In this case,
330-
// the `#` ID is generated by the exporter since it has no base document to baseline against. In this
331-
// case we we want to replace the reference ID with the schema ID that was generated by the
332-
// `CreateSchemaReferenceId` method in the OpenApiSchemaService.
333-
if (schema.Metadata is not null &&
334-
schema.Metadata.TryGetValue(OpenApiConstants.RefId, out var refId) &&
335-
refId is string refIdString)
340+
if (resultSchemaReference is not null)
336341
{
337-
if (schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var schemaId) &&
338-
schemaId is string schemaIdString)
339-
{
340-
return new OpenApiSchemaReference(schemaIdString, document);
341-
}
342-
var relativeSchemaId = $"#/components/schemas/{rootSchemaId}{refIdString.Replace("#", string.Empty)}";
343-
return new OpenApiSchemaReference(relativeSchemaId, document);
344-
}
345-
346-
// If we're resolving schemas for a top-level schema being referenced in the `components.schema` property
347-
// we don't want to replace the top-level inline schema with a reference to itself. We want to replace
348-
// inline schemas to reference schemas for all schemas referenced in the top-level schema though (such as
349-
// `allOf`, `oneOf`, `anyOf`, `items`, `properties`, etc.) which is why `isTopLevel` is only set once.
350-
if (schema is OpenApiSchema && schema.Metadata is not null &&
351-
!schema.Metadata.ContainsKey(OpenApiConstants.RefId) &&
352-
schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var referenceId) &&
353-
referenceId is string referenceIdString)
354-
{
355-
var targetReferenceId = baseSchemaId is not null
356-
? $"{baseSchemaId}{referenceIdString}"
357-
: referenceIdString;
358-
if (!string.IsNullOrEmpty(targetReferenceId))
359-
{
360-
return document.AddOpenApiSchemaByReference(targetReferenceId, schema);
361-
}
342+
return resultSchemaReference;
362343
}
363344

364345
return schema;
@@ -466,9 +447,9 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema,
466447
}
467448
}
468449

469-
private JsonNode CreateSchema(OpenApiSchemaKey key)
450+
private JsonNode CreateSchema(Type type)
470451
{
471-
var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration);
452+
var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, type, _configuration);
472453
return ResolveReferences(schema, schema);
473454
}
474455

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -984,8 +984,9 @@ private void VerifyOptionalEnum(OpenApiDocument document)
984984
var property = properties["status"];
985985

986986
Assert.NotNull(property);
987-
Assert.Equal(3, property.Enum.Count);
988-
Assert.Equal("Approved", property.Default.GetValue<string>());
987+
var statusReference = Assert.IsType<OpenApiSchemaReference>(property);
988+
Assert.Equal(3, statusReference.RecursiveTarget.Enum.Count);
989+
Assert.Equal("Approved", statusReference.Default.GetValue<string>());
989990
}
990991

991992
[ApiController]

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,38 @@ await VerifyOpenApiDocument(builder, document =>
11221122
});
11231123
}
11241124

1125+
// Test for: https://github.com/dotnet/aspnetcore/issues/64048
1126+
public static object[][] CircularReferencesWithArraysHandlers =>
1127+
[
1128+
[(CircularReferenceWithArrayRootOrderArrayFirst dto) => { }],
1129+
[(CircularReferenceWithArrayRootOrderArrayLast dto) => { }],
1130+
];
1131+
1132+
[Theory]
1133+
[MemberData(nameof(CircularReferencesWithArraysHandlers))]
1134+
public async Task HandlesCircularReferencesWithArraysRegardlessOfPropertyOrder(Delegate requestHandler)
1135+
{
1136+
var builder = CreateBuilder();
1137+
builder.MapPost("/", requestHandler);
1138+
1139+
await VerifyOpenApiDocument(builder, (OpenApiDocument document) =>
1140+
{
1141+
Assert.NotNull(document.Components?.Schemas);
1142+
var schema = document.Components.Schemas["CircularReferenceWithArrayModel"];
1143+
Assert.Equal(JsonSchemaType.Object, schema.Type);
1144+
Assert.NotNull(schema.Properties);
1145+
Assert.Collection(schema.Properties,
1146+
property =>
1147+
{
1148+
Assert.Equal("selfArray", property.Key);
1149+
var arraySchema = Assert.IsType<OpenApiSchema>(property.Value);
1150+
Assert.Equal(JsonSchemaType.Array, arraySchema.Type);
1151+
var itemReference = Assert.IsType<OpenApiSchemaReference>(arraySchema.Items);
1152+
Assert.Equal("#/components/schemas/CircularReferenceWithArrayModel", itemReference.Reference.ReferenceV3);
1153+
});
1154+
});
1155+
}
1156+
11251157
// Test models for issue 61194
11261158
private class Config
11271159
{
@@ -1203,5 +1235,23 @@ private class ReferencedModel
12031235
{
12041236
public int Id { get; set; }
12051237
}
1238+
1239+
// Test models for issue 64048
1240+
public class CircularReferenceWithArrayRootOrderArrayLast
1241+
{
1242+
public CircularReferenceWithArrayModel Item { get; set; } = null!;
1243+
public ICollection<CircularReferenceWithArrayModel> ItemArray { get; set; } = [];
1244+
}
1245+
1246+
public class CircularReferenceWithArrayRootOrderArrayFirst
1247+
{
1248+
public ICollection<CircularReferenceWithArrayModel> ItemArray { get; set; } = [];
1249+
public CircularReferenceWithArrayModel Item { get; set; } = null!;
1250+
}
1251+
1252+
public class CircularReferenceWithArrayModel
1253+
{
1254+
public ICollection<CircularReferenceWithArrayModel> SelfArray { get; set; } = [];
1255+
}
12061256
}
12071257
#nullable restore

0 commit comments

Comments
 (0)