Skip to content

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

Merged
merged 5 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/OpenApi/sample/EndpointRouteBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,92 @@ public static IEndpointConventionBuilder MapSwaggerUi(this IEndpointRouteBuilder
</html>
""", "text/html")).ExcludeFromDescription();
}

public static IEndpointRouteBuilder MapTypesWithRef(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/category", (Category category) =>
{
return Results.Ok(category);
});
endpoints.MapPost("/container", (ContainerType container) =>
{
return Results.Ok(container);
});
endpoints.MapPost("/root", (Root root) =>
{
return Results.Ok(root);
});
endpoints.MapPost("/location", (LocationContainer location) =>
{
return Results.Ok(location);
});
endpoints.MapPost("/parent", (ParentObject parent) =>
{
return Results.Ok(parent);
});
endpoints.MapPost("/child", (ChildObject child) =>
{
return Results.Ok(child);
});
return endpoints;
}

public sealed class Category
{
public required string Name { get; set; }

public required Category Parent { get; set; }

public IEnumerable<Tag> Tags { get; set; } = [];
}

public sealed class Tag
{
public required string Name { get; set; }
}

public sealed class ContainerType
{
public List<List<string>> Seq1 { get; set; } = [];
public List<List<string>> Seq2 { get; set; } = [];
}

public sealed class Root
{
public Item Item1 { get; set; } = null!;
public Item Item2 { get; set; } = null!;
}

public sealed class Item
{
public string[] Name { get; set; } = null!;
public int value { get; set; }
}

public sealed class LocationContainer
{
public required LocationDto Location { get; set; }
}

public sealed class LocationDto
{
public required AddressDto Address { get; set; }
}

public sealed class AddressDto
{
public required LocationDto RelatedLocation { get; set; }
}

public sealed class ParentObject
{
public int Id { get; set; }
public List<ChildObject> Children { get; set; } = [];
}

public sealed class ChildObject
{
public int Id { get; set; }
public required ParentObject Parent { get; set; }
}
}
1 change: 1 addition & 0 deletions src/OpenApi/sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
schemas.MapPost("/shape", (Shape shape) => { });
schemas.MapPost("/weatherforecastbase", (WeatherForecastBase forecast) => { });
schemas.MapPost("/person", (Person person) => { });
schemas.MapTypesWithRef();

app.MapControllers();

Expand Down
27 changes: 24 additions & 3 deletions src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,30 @@ public bool Equals(OpenApiSchema? x, OpenApiSchema? y)
return true;
}

// If a local reference is present, we can't compare the schema directly
// and should instead use the schema ID as a type-check to assert if the schemas are
// equivalent.
// If both have references, compare the final segments to handle
// equivalent types in different contexts, like the same schema
// in a dictionary value or list like "#/components/schemas/#/additionalProperties/properties/location/properties/address"
if (x.Reference != null && y.Reference != null)
{
if (x.Reference.Id.StartsWith("#", StringComparison.OrdinalIgnoreCase) &&
y.Reference.Id.StartsWith("#", StringComparison.OrdinalIgnoreCase) &&
x.Reference.ReferenceV3 is string xFullReferencePath &&
y.Reference.ReferenceV3 is string yFullReferencePath)
{
// Compare the last segments of the reference paths
Copy link
Member Author

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 and List<ParentObject> differ like this:

using System.Text.Json.Nodes;
using System.Text.Json;
using System.Text.Json.Schema;
using System.Collections.Generic;
using System;

var schema1 = JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions.Web, typeof(ParentObject));
var schema2 = JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions.Web, typeof(List<ParentObject>));
Console.WriteLine(schema1.ToJsonString());
Console.WriteLine(schema2.ToJsonString());


public class ParentObject
{
	public int Id { get; set; }
	public List<ChildObject> Children { get; set; } = [];
}

public class ChildObject
{
	public int Id { get; set; }
	public required ParentObject Parent { get; set; }
}

ParentObject:

{
    "type": [
        "object",
        "null"
    ],
    "properties": {
        "id": {
            "type": [
                "string",
                "integer"
            ],
            "pattern": "^-?(?:0|[1-9]\\d*)$"
        },
        "children": {
            "type": [
                "array",
                "null"
            ],
            "items": {
                "type": [
                    "object",
                    "null"
                ],
                "properties": {
                    "id": {
                        "type": [
                            "string",
                            "integer"
                        ],
                        "pattern": "^-?(?:0|[1-9]\\d*)$"
                    },
                    "parent": {
                        "type": [
                            "object",
                            "null"
                        ],
                        "properties": {
                            "id": {
                                "type": [
                                    "string",
                                    "integer"
                                ],
                                "pattern": "^-?(?:0|[1-9]\\d*)$"
                            },
                            "children": {
                                "$ref": "#/properties/children" // Same as below
                            }
                        }
                    }
                },
                "required": [
                    "parent"
                ]
            }
        }
    }
} 

List

{
    "type": [
        "array",
        "null"
    ],
    "items": {
        "type": [
            "object",
            "null"
        ],
        "properties": {
            "id": {
                "type": [
                    "string",
                    "integer"
                ],
                "pattern": "^-?(?:0|[1-9]\\d*)$"
            },
            "children": {
                "type": [
                    "array",
                    "null"
                ],
                "items": {
                    "type": [
                        "object",
                        "null"
                    ],
                    "properties": {
                        "id": {
                            "type": [
                                "string",
                                "integer"
                            ],
                            "pattern": "^-?(?:0|[1-9]\\d*)$"
                        },
                        "parent": {
                            "type": [
                                "object",
                                "null"
                            ],
                            "properties": {
                                "id": {
                                    "type": [
                                        "string",
                                        "integer"
                                    ],
                                    "pattern": "^-?(?:0|[1-9]\\d*)$"
                                },
                                "children": {
                                    "$ref": "#/items/properties/children" // This is the same as children not in a list
                                }
                            }
                        }
                    },
                    "required": [
                        "parent"
                    ]
                }
            }
        }
    }
}

// to handle equivalent types in different contexts,
// like the same schema in a dictionary value or list
var xLastIndexOf = xFullReferencePath.LastIndexOf('/');
var yLastIndexOf = yFullReferencePath.LastIndexOf('/');

if (xLastIndexOf != -1 && yLastIndexOf != -1)
{
return xFullReferencePath.AsSpan(xLastIndexOf).Equals(yFullReferencePath.AsSpan(yLastIndexOf), StringComparison.OrdinalIgnoreCase);
}
}
}

// If only one has a reference, compare using schema IDs
if ((x.Reference != null && y.Reference == null)
|| (x.Reference == null && y.Reference != null))
{
Expand Down
115 changes: 110 additions & 5 deletions src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
Expand Down Expand Up @@ -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) &&
Copy link
Member Author

Choose a reason for hiding this comment

The 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 List<string>. For things that have a schema ID, we create a ref to the schema in the OpenApiComponents via the OpenApiSchemaReferenceTransformer.

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
Expand Up @@ -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))
Copy link
Member Author

Choose a reason for hiding this comment

The 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 OpenApiSchemaService. We retain the $ref so that we can use it as a comparison shorthand in OpenApiSchemaComparer. Here, before we resolve all the schemas in the document, we remove the relative reference so that the schema can be inlined using the properties that were copied over.

{
schema.Reference = null;
}

if (schema.AllOf is not null)
{
for (var i = 0; i < schema.AllOf.Count; i++)
Expand Down
Loading
Loading