-
Notifications
You must be signed in to change notification settings - Fork 10.3k
Fix self-referential schema handling in collection schemas #60410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b97f561
5b543da
174d4f1
7e54ddd
4229e24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -102,7 +102,7 @@ internal sealed class OpenApiSchemaService( | |
// "nested": "#/properties/nested" becomes "nested": "#/components/schemas/NestedType" | ||
if (jsonPropertyInfo.PropertyType == jsonPropertyInfo.DeclaringType) | ||
{ | ||
return new JsonObject { [OpenApiSchemaKeywords.RefKeyword] = createSchemaReferenceId(context.TypeInfo) }; | ||
schema[OpenApiSchemaKeywords.RefKeyword] = createSchemaReferenceId(context.TypeInfo); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to override just the ref keyword so we can keep the other transformations that happened before this LoC intact (like setting the x-schema-id property). |
||
} | ||
schema.ApplyNullabilityContextInfo(jsonPropertyInfo); | ||
} | ||
|
@@ -213,13 +213,118 @@ private async Task InnerApplySchemaTransformersAsync(OpenApiSchema schema, | |
} | ||
} | ||
|
||
if (schema is { AdditionalPropertiesAllowed: true, AdditionalProperties: not null } && jsonTypeInfo.ElementType is not null) | ||
{ | ||
if (schema is { AdditionalPropertiesAllowed: true, AdditionalProperties: not null } && jsonTypeInfo.ElementType is not null) | ||
{ | ||
var elementTypeInfo = _jsonSerializerOptions.GetTypeInfo(jsonTypeInfo.ElementType); | ||
await InnerApplySchemaTransformersAsync(schema.AdditionalProperties, elementTypeInfo, null, context, transformer, cancellationToken); | ||
} | ||
} | ||
} | ||
|
||
private JsonNode CreateSchema(OpenApiSchemaKey key) | ||
=> JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration); | ||
{ | ||
var sourceSchema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration); | ||
|
||
// Resolve any relative references in the schema | ||
ResolveRelativeReferences(sourceSchema, sourceSchema); | ||
|
||
return sourceSchema; | ||
} | ||
|
||
// Helper method to recursively resolve relative references in a schema | ||
private static void ResolveRelativeReferences(JsonNode node, JsonNode rootNode) | ||
{ | ||
if (node is JsonObject jsonObj) | ||
{ | ||
// Check if this node has a $ref property with a relative reference and no schemaId to | ||
// resolve to | ||
if (jsonObj.TryGetPropertyValue(OpenApiSchemaKeywords.RefKeyword, out var refNode) && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not having a schema ID indicates that the relative reference is to something that isn't a complex type, for example a |
||
refNode is JsonValue refValue && | ||
refValue.TryGetValue<string>(out var refPath) && | ||
refPath.StartsWith("#/", StringComparison.OrdinalIgnoreCase) && | ||
!jsonObj.TryGetPropertyValue(OpenApiConstants.SchemaId, out var schemaId) && | ||
schemaId is null) | ||
{ | ||
// Found a relative reference, resolve it | ||
var resolvedNode = ResolveJsonPointer(rootNode, refPath); | ||
if (resolvedNode != null) | ||
{ | ||
// Copy all properties from the resolved node | ||
if (resolvedNode is JsonObject resolvedObj) | ||
{ | ||
foreach (var property in resolvedObj) | ||
{ | ||
// Clone the property value to avoid modifying the original | ||
var clonedValue = property.Value != null | ||
? JsonNode.Parse(property.Value.ToJsonString()) | ||
: null; | ||
|
||
jsonObj[property.Key] = clonedValue; | ||
} | ||
} | ||
} | ||
} | ||
else | ||
{ | ||
// Recursively process all properties | ||
foreach (var property in jsonObj) | ||
{ | ||
if (property.Value is JsonNode propNode) | ||
{ | ||
ResolveRelativeReferences(propNode, rootNode); | ||
} | ||
} | ||
} | ||
} | ||
else if (node is JsonArray jsonArray) | ||
{ | ||
// Process each item in the array | ||
for (var i = 0; i < jsonArray.Count; i++) | ||
{ | ||
if (jsonArray[i] is JsonNode arrayItem) | ||
{ | ||
ResolveRelativeReferences(arrayItem, rootNode); | ||
} | ||
} | ||
} | ||
} | ||
|
||
// Helper method to resolve a JSON pointer path and return the referenced node | ||
private static JsonNode? ResolveJsonPointer(JsonNode root, string pointer) | ||
{ | ||
if (string.IsNullOrEmpty(pointer) || !pointer.StartsWith("#/", StringComparison.OrdinalIgnoreCase)) | ||
{ | ||
return null; // Invalid pointer | ||
} | ||
|
||
// Remove the leading "#/" and split the path into segments | ||
var jsonPointer = pointer.AsSpan(2); | ||
var segments = jsonPointer.Split('/'); | ||
var currentNode = root; | ||
|
||
foreach (var segment in segments) | ||
{ | ||
if (currentNode is JsonObject jsonObj) | ||
{ | ||
if (!jsonObj.TryGetPropertyValue(jsonPointer[segment].ToString(), out var nextNode)) | ||
{ | ||
return null; // Path segment not found | ||
} | ||
currentNode = nextNode; | ||
} | ||
else if (currentNode is JsonArray jsonArray && int.TryParse(jsonPointer[segment], out var index)) | ||
{ | ||
if (index < 0 || index >= jsonArray.Count) | ||
{ | ||
return null; // Index out of range | ||
} | ||
currentNode = jsonArray[index]; | ||
} | ||
else | ||
{ | ||
return null; // Cannot navigate further | ||
} | ||
} | ||
|
||
return currentNode; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -112,6 +112,13 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC | |
return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = schemaId?.ToString() } }; | ||
} | ||
|
||
// Handle relative schemas that don't point to the parent document but to another property in the same type. | ||
// In this case, remove the reference and rely on the properties that have been resolved and copied by the OpenApiSchemaService. | ||
if (schema.Reference is { Type: ReferenceType.Schema, Id: var id } && id.StartsWith("#/", StringComparison.Ordinal)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We resolve the JSON pointer that this ref points to in the new code |
||
{ | ||
schema.Reference = null; | ||
} | ||
|
||
if (schema.AllOf is not null) | ||
{ | ||
for (var i = 0; i < schema.AllOf.Count; i++) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
STJ will use different relative references for the same type depending on its entrypoint. This comparison helps us evaluate relative references with different structure that point to the same underlying property. For example, the schemas for
ParentObject
andList<ParentObject>
differ like this:ParentObject:
List