From b97f5618e7edda3193b1fd583dc9c0882ba71d44 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 14 Feb 2025 22:17:09 -0800 Subject: [PATCH 1/5] Fix self-referential schema handling in collection schemas --- .../src/Comparers/OpenApiSchemaComparer.cs | 27 ++++++++-- .../OpenApiSchemaReferenceTransformerTests.cs | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs index 2e69b10f213f..f7e615956ddf 100644 --- a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs +++ b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs @@ -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 + // to handle equivalent types in different contexts, + // like the same schema in a dictionary value or list + var xRefParts = xFullReferencePath.Split('/'); + var yRefParts = yFullReferencePath.Split('/'); + + if (xRefParts?.Length > 0 && yRefParts?.Length > 0) + { + return xRefParts[^1] == yRefParts[^1]; + } + } + } + + // If only one has a reference, compare using schema IDs if ((x.Reference != null && y.Reference == null) || (x.Reference == null && y.Reference != null)) { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs index 7209715e3516..caa585047319 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs @@ -508,6 +508,58 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task SupportsListOfNestedSchemasWithSelfReference() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/list", (List items) => { }); + builder.MapPost("/array", (LocationContainer[] items) => { }); + builder.MapPost("/dictionary", (Dictionary items) => { }); + builder.MapPost("/", (LocationContainer item) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var listOperation = document.Paths["/list"].Operations[OperationType.Post]; + var listRequestSchema = listOperation.RequestBody.Content["application/json"].Schema; + + var arrayOperation = document.Paths["/array"].Operations[OperationType.Post]; + var arrayRequestSchema = arrayOperation.RequestBody.Content["application/json"].Schema; + + var dictionaryOperation = document.Paths["/dictionary"].Operations[OperationType.Post]; + var dictionaryRequestSchema = dictionaryOperation.RequestBody.Content["application/json"].Schema; + + var operation = document.Paths["/"].Operations[OperationType.Post]; + var requestSchema = operation.RequestBody.Content["application/json"].Schema; + + // Assert $ref used for top-level + Assert.Equal("LocationContainer", listRequestSchema.Items.Reference.Id); + Assert.Equal("LocationContainer", arrayRequestSchema.Items.Reference.Id); + Assert.Equal("LocationContainer", dictionaryRequestSchema.AdditionalProperties.Reference.Id); + Assert.Equal("LocationContainer", requestSchema.Reference.Id); + + // Assert that $ref is used for nested LocationDto + var locationContainerSchema = requestSchema.GetEffective(document); + Assert.Equal("LocationDto", locationContainerSchema.Properties["location"].Reference.Id); + + // Assert that $ref is used for nested AddressDto + var locationSchema = locationContainerSchema.Properties["location"].GetEffective(document); + Assert.Equal("AddressDto", locationSchema.Properties["address"].Reference.Id); + + // Assert that $ref is used for related LocationDto + var addressSchema = locationSchema.Properties["address"].GetEffective(document); + Assert.Equal("LocationDto", addressSchema.Properties["relatedLocation"].Reference.Id); + + // Assert that only expected schemas are generated at the top-level + Assert.Equal(3, document.Components.Schemas.Count); + Assert.Collection(document.Components.Schemas.Keys, + key => Assert.Equal("AddressDto", key), + key => Assert.Equal("LocationContainer", key), + key => Assert.Equal("LocationDto", key)); + }); + } + [Fact] public async Task SupportsListNestedSchemasWithSelfReference() { From 5b543da870f788ff6f0aadeb5d182307d445d273 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 5 Mar 2025 11:10:37 -0800 Subject: [PATCH 2/5] Resolve relative references and add more tests --- .../Services/Schemas/OpenApiSchemaService.cs | 114 +++++++++++- .../OpenApiSchemaReferenceTransformerTests.cs | 166 +++++++++++++++++- 2 files changed, 271 insertions(+), 9 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 537eb5d5db72..99553af8bd8b 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -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); } schema.ApplyNullabilityContextInfo(jsonPropertyInfo); } @@ -213,13 +213,117 @@ 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) && + refNode is JsonValue refValue && + refValue.TryGetValue(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 segments = pointer[2..].Split('/'); + var currentNode = root; + + foreach (var segment in segments) + { + if (currentNode is JsonObject jsonObj) + { + if (!jsonObj.TryGetPropertyValue(segment, out var nextNode)) + { + return null; // Path segment not found + } + currentNode = nextNode; + } + else if (currentNode is JsonArray jsonArray && int.TryParse(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; + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs index caa585047319..f8d46f771ca1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs @@ -505,6 +505,9 @@ await VerifyOpenApiDocument(builder, document => // Assert that $ref is used for related LocationDto var addressSchema = locationSchema.Properties["address"].GetEffective(document); Assert.Equal("LocationDto", addressSchema.Properties["relatedLocation"].Reference.Id); + + // Assert that only expected schemas are generated at the top-level + Assert.Equal(["AddressDto", "LocationContainer", "LocationDto"], document.Components.Schemas.Keys); }); } @@ -553,10 +556,7 @@ await VerifyOpenApiDocument(builder, document => // Assert that only expected schemas are generated at the top-level Assert.Equal(3, document.Components.Schemas.Count); - Assert.Collection(document.Components.Schemas.Keys, - key => Assert.Equal("AddressDto", key), - key => Assert.Equal("LocationContainer", key), - key => Assert.Equal("LocationDto", key)); + Assert.Equal(["AddressDto", "LocationContainer", "LocationDto"], document.Components.Schemas.Keys); }); } @@ -583,6 +583,9 @@ await VerifyOpenApiDocument(builder, document => // Assert that $ref is used for nested Parent var childSchema = parentSchema.Properties["children"].Items.GetEffective(document); Assert.Equal("ParentObject", childSchema.Properties["parent"].Reference.Id); + + // Assert that only the expected schemas are registered + Assert.Equal(["ChildObject", "ParentObject"], document.Components.Schemas.Keys); }); } @@ -611,6 +614,161 @@ await VerifyOpenApiDocument(builder, document => }); } + // Test for: https://github.com/dotnet/aspnetcore/issues/60381 + [Fact] + public async Task ResolvesListBasedReferencesCorrectly() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/", (ContainerType item) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/"].Operations[OperationType.Post]; + var requestSchema = operation.RequestBody.Content["application/json"].Schema; + + // Assert $ref used for top-level + Assert.Equal("ContainerType", requestSchema.Reference.Id); + + // Get effective schema for ContainerType + var containerSchema = requestSchema.GetEffective(document); + Assert.Equal(2, containerSchema.Properties.Count); + + // Check Seq1 and Seq2 properties + var seq1Schema = containerSchema.Properties["seq1"]; + var seq2Schema = containerSchema.Properties["seq2"]; + + // Assert both are array types + Assert.Equal("array", seq1Schema.Type); + Assert.Equal("array", seq2Schema.Type); + + // Assert items are arrays of strings + Assert.Equal("array", seq1Schema.Items.Type); + Assert.Equal("array", seq2Schema.Items.Type); + + // Since both Seq1 and Seq2 are the same type (List>), + // they should reference the same schema structure + Assert.Equal(seq1Schema.Items.Type, seq2Schema.Items.Type); + + // Verify the inner arrays contain strings + Assert.Equal("string", seq1Schema.Items.Items.Type); + Assert.Equal("string", seq2Schema.Items.Items.Type); + + Assert.Equal(["ContainerType"], document.Components.Schemas.Keys); + }); + } + + // Tests for: https://github.com/dotnet/aspnetcore/issues/60012 + [Fact] + public async Task SupportsListOfClassInSelfReferentialSchema() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/", (Category item) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/"].Operations[OperationType.Post]; + var requestSchema = operation.RequestBody.Content["application/json"].Schema; + + // Assert $ref used for top-level + Assert.Equal("Category", requestSchema.Reference.Id); + + // Assert that $ref is used for nested Tags + var categorySchema = requestSchema.GetEffective(document); + Assert.Equal("Tag", categorySchema.Properties["tags"].Items.Reference.Id); + + // Assert that $ref is used for nested Parent + Assert.Equal("Category", categorySchema.Properties["parent"].Reference.Id); + + // Assert that no duplicate schemas are emitted + Assert.Collection(document.Components.Schemas, + schema => + { + Assert.Equal("Category", schema.Key); + }, + schema => + { + Assert.Equal("Tag", schema.Key); + }); + }); + } + + [Fact] + public async Task UsesSameReferenceForSameTypeInDifferentLocations() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/parent-object", (ParentObject item) => { }); + builder.MapPost("/list", (List item) => { }); + builder.MapPost("/dictionary", (Dictionary item) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/parent-object"].Operations[OperationType.Post]; + var requestSchema = operation.RequestBody.Content["application/json"].Schema; + + // Assert $ref used for top-level + Assert.Equal("ParentObject", requestSchema.Reference.Id); + + // Assert that $ref is used for nested Children + var parentSchema = requestSchema.GetEffective(document); + Assert.Equal("ChildObject", parentSchema.Properties["children"].Items.Reference.Id); + + // Assert that $ref is used for nested Parent + var childSchema = parentSchema.Properties["children"].Items.GetEffective(document); + Assert.Equal("ParentObject", childSchema.Properties["parent"].Reference.Id); + + operation = document.Paths["/list"].Operations[OperationType.Post]; + requestSchema = operation.RequestBody.Content["application/json"].Schema; + + // Assert $ref used for items in the list definition + Assert.Equal("ParentObject", requestSchema.Items.Reference.Id); + parentSchema = requestSchema.Items.GetEffective(document); + Assert.Equal("ChildObject", parentSchema.Properties["children"].Items.Reference.Id); + + childSchema = parentSchema.Properties["children"].Items.GetEffective(document); + Assert.Equal("ParentObject", childSchema.Properties["parent"].Reference.Id); + + operation = document.Paths["/dictionary"].Operations[OperationType.Post]; + requestSchema = operation.RequestBody.Content["application/json"].Schema; + + // Assert $ref used for items in the dictionary definition + Assert.Equal("ParentObject", requestSchema.AdditionalProperties.Reference.Id); + parentSchema = requestSchema.AdditionalProperties.GetEffective(document); + Assert.Equal("ChildObject", parentSchema.Properties["children"].Items.Reference.Id); + + childSchema = parentSchema.Properties["children"].Items.GetEffective(document); + Assert.Equal("ParentObject", childSchema.Properties["parent"].Reference.Id); + + // Assert that only the expected schemas are registered + Assert.Equal(["ChildObject", "ParentObject"], document.Components.Schemas.Keys); + }); + } + + private class Category + { + public required string Name { get; set; } + + public Category Parent { get; set; } + + public IEnumerable Tags { get; set; } = []; + } + + public class Tag + { + public required string Name { get; set; } + } + + private class ContainerType + { + public List> Seq1 { get; set; } = []; + public List> Seq2 { get; set; } = []; + } + private class Root { public Item Item1 { get; set; } = null!; From 174d4f132695619763ba70fe843bc5e34a38b564 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 6 Mar 2025 22:03:39 -0800 Subject: [PATCH 3/5] Add snapshot tests and prune more refs --- .../sample/EndpointRouteBuilderExtensions.cs | 88 ++++++ src/OpenApi/sample/Program.cs | 1 + .../OpenApiSchemaReferenceTransformer.cs | 7 + ...t_documentName=schemas-by-ref.verified.txt | 276 ++++++++++++++++++ 4 files changed, 372 insertions(+) diff --git a/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs index fd196d7fc101..acf7ae1bd41d 100644 --- a/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs @@ -43,4 +43,92 @@ public static IEndpointConventionBuilder MapSwaggerUi(this IEndpointRouteBuilder """, "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 Tags { get; set; } = []; + } + + public sealed class Tag + { + public required string Name { get; set; } + } + + public sealed class ContainerType + { + public List> Seq1 { get; set; } = []; + public List> 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 Children { get; set; } = []; + } + + public sealed class ChildObject + { + public int Id { get; set; } + public required ParentObject Parent { get; set; } + } } diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index a622780ff482..e2a1c4c0866f 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -113,6 +113,7 @@ schemas.MapPost("/shape", (Shape shape) => { }); schemas.MapPost("/weatherforecastbase", (WeatherForecastBase forecast) => { }); schemas.MapPost("/person", (Person person) => { }); +schemas.MapTypesWithRef(); app.MapControllers(); diff --git a/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs b/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs index ee7e166daab7..aa98a21894ff 100644 --- a/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs +++ b/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs @@ -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)) + { + schema.Reference = null; + } + if (schema.AllOf is not null) { for (var i = 0; i < schema.AllOf.Count; i++) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index cd00d261b632..3e5373a7e36b 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -375,6 +375,138 @@ } } } + }, + "/schemas-by-ref/category": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/container": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContainerType" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/root": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Root" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/location": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationContainer" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/parent": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParentObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/child": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChildObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -391,6 +523,128 @@ } } }, + "AddressDto": { + "required": [ + "relatedLocation" + ], + "type": "object", + "properties": { + "relatedLocation": { + "$ref": "#/components/schemas/LocationDto" + } + } + }, + "Category": { + "required": [ + "name", + "parent" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/Category" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + } + } + }, + "ChildObject": { + "required": [ + "parent" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "parent": { + "$ref": "#/components/schemas/ParentObject" + } + } + }, + "ContainerType": { + "type": "object", + "properties": { + "seq1": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "seq2": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "Item": { + "type": "object", + "properties": { + "name": { + "type": "array", + "items": { + "type": "string" + } + }, + "value": { + "type": "integer", + "format": "int32" + } + } + }, + "LocationContainer": { + "required": [ + "location" + ], + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationDto" + } + } + }, + "LocationDto": { + "required": [ + "address" + ], + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/AddressDto" + } + } + }, + "ParentObject": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChildObject" + } + } + } + }, "Person": { "required": [ "discriminator" @@ -454,6 +708,17 @@ } } }, + "Root": { + "type": "object", + "properties": { + "item1": { + "$ref": "#/components/schemas/Item" + }, + "item2": { + "$ref": "#/components/schemas/Item" + } + } + }, "Shape": { "required": [ "$type" @@ -517,6 +782,17 @@ } } }, + "Tag": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "Triangle": { "type": "object", "properties": { From 7e54ddd56790cda42d33b75f9bbbdaabd4098aa0 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 7 Mar 2025 15:40:22 -0800 Subject: [PATCH 4/5] Perf for comparer and resolution --- src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs | 8 ++++---- src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs index f7e615956ddf..1e47529e5cc4 100644 --- a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs +++ b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs @@ -37,12 +37,12 @@ x.Reference.ReferenceV3 is string xFullReferencePath && // Compare the last segments of the reference paths // to handle equivalent types in different contexts, // like the same schema in a dictionary value or list - var xRefParts = xFullReferencePath.Split('/'); - var yRefParts = yFullReferencePath.Split('/'); + var xLastIndexOf = xFullReferencePath.LastIndexOf('/'); + var yLastIndexOf = yFullReferencePath.LastIndexOf('/'); - if (xRefParts?.Length > 0 && yRefParts?.Length > 0) + if (xLastIndexOf != -1 && yLastIndexOf != -1) { - return xRefParts[^1] == yRefParts[^1]; + return xFullReferencePath[xLastIndexOf..] == yFullReferencePath[yLastIndexOf..];; } } } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 99553af8bd8b..812f896ee25d 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -297,20 +297,21 @@ refNode is JsonValue refValue && } // Remove the leading "#/" and split the path into segments - var segments = pointer[2..].Split('/'); + 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(segment, out var nextNode)) + 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(segment, out var index)) + else if (currentNode is JsonArray jsonArray && int.TryParse(jsonPointer[segment], out var index)) { if (index < 0 || index >= jsonArray.Count) { From 4229e246540878d38c5ef2a0a2f78f8545a7ea07 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 10 Mar 2025 12:45:53 -0700 Subject: [PATCH 5/5] Use span comparison for reference path --- src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs index 1e47529e5cc4..46f91cd8a494 100644 --- a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs +++ b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs @@ -42,7 +42,7 @@ x.Reference.ReferenceV3 is string xFullReferencePath && if (xLastIndexOf != -1 && yLastIndexOf != -1) { - return xFullReferencePath[xLastIndexOf..] == yFullReferencePath[yLastIndexOf..];; + return xFullReferencePath.AsSpan(xLastIndexOf).Equals(yFullReferencePath.AsSpan(yLastIndexOf), StringComparison.OrdinalIgnoreCase); } } }