From 7ba431827ac6d3114e790a6042e3b5bdd98655a4 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 13 Aug 2025 22:01:10 -0700 Subject: [PATCH 1/5] Resolve relative references before processing --- .../Services/Schemas/OpenApiSchemaService.cs | 108 +++++++++++++++++- ...t_documentName=schemas-by-ref.verified.txt | 15 ++- ...t_documentName=schemas-by-ref.verified.txt | 15 ++- ...ifyOpenApiDocumentIsInvariant.verified.txt | 15 ++- .../OpenApiSchemaReferenceTransformerTests.cs | 3 +- 5 files changed, 144 insertions(+), 12 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index bb2fb3d4fc12..c8830a30c722 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -457,5 +457,111 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema, } private JsonNode CreateSchema(OpenApiSchemaKey key) - => JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration); + { + var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration); + return ResolveReferences(schema, schema); + } + + /// + /// Recursively resolves references within a JSON schema node. + /// + private static JsonNode ResolveReferences(JsonNode node, JsonNode rootSchema) + { + if (node is JsonObject jsonObject) + { + // Check if this is a reference object + if (jsonObject.TryGetPropertyValue("$ref", out var refNode) && + refNode is JsonValue refValue && + refValue.TryGetValue(out var refString) && + refString.StartsWith('#')) + { + // Resolve the reference path to the actual schema content + var resolvedNode = ResolveReference(refString, rootSchema); + if (resolvedNode != null) + { + // Return a deep clone to avoid parent issues + return resolvedNode.DeepClone(); + } + // If resolution fails, return the original reference + return node; + } + + // Process all properties recursively + var newObject = new JsonObject(); + foreach (var property in jsonObject) + { + if (property.Value != null) + { + var processedValue = ResolveReferences(property.Value, rootSchema); + // Clone the processed value to avoid parent issues + newObject[property.Key] = processedValue?.DeepClone(); + } + else + { + newObject[property.Key] = null; + } + } + return newObject; + } + else if (node is JsonArray jsonArray) + { + var newArray = new JsonArray(); + for (var i = 0; i < jsonArray.Count; i++) + { + if (jsonArray[i] != null) + { + var processedValue = ResolveReferences(jsonArray[i]!, rootSchema); + // Clone the processed value to avoid parent issues + newArray.Add(processedValue?.DeepClone()); + } + else + { + newArray.Add(null); + } + } + return newArray; + } + + // Return primitive values as-is + return node; + } + + /// + /// Resolves a JSON reference path (like "#/properties/parent/properties/tags") to the actual schema content. + /// + private static JsonNode? ResolveReference(string refPath, JsonNode rootSchema) + { + // Remove the leading "#" and split the path + var path = refPath.TrimStart('#').TrimStart('/'); + if (string.IsNullOrEmpty(path)) + { + return rootSchema; + } + + var segments = path.Split('/'); + var current = rootSchema; + + foreach (var segment in segments) + { + if (current is JsonObject currentObject) + { + if (currentObject.TryGetPropertyValue(segment, out var nextNode) && nextNode != null) + { + current = nextNode; + } + else + { + // Path not found + return null; + } + } + else + { + // Cannot navigate further + return null; + } + } + + return current; + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index f056bc064c9b..ab8542c88535 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -611,7 +611,10 @@ "$ref": "#/components/schemas/Category" }, "tags": { - "$ref": "#/components/schemas/Category/properties/parent/properties/tags" + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } } } }, @@ -645,7 +648,10 @@ "seq2": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerType/properties/seq1/items" + "type": "array", + "items": { + "type": "string" + } } } } @@ -665,7 +671,10 @@ "type": "object", "properties": { "name": { - "$ref": "#/components/schemas/Root/properties/item1/properties/name" + "type": "array", + "items": { + "type": "string" + } }, "value": { "type": "integer", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 6959e68ce4b3..2c0be542ed26 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -611,7 +611,10 @@ "$ref": "#/components/schemas/Category" }, "tags": { - "$ref": "#/components/schemas/Category/properties/parent/properties/tags" + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } } } }, @@ -645,7 +648,10 @@ "seq2": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerType/properties/seq1/items" + "type": "array", + "items": { + "type": "string" + } } } } @@ -665,7 +671,10 @@ "type": "object", "properties": { "name": { - "$ref": "#/components/schemas/Root/properties/item1/properties/name" + "type": "array", + "items": { + "type": "string" + } }, "value": { "type": "integer", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index a5abfc5f0433..bd67f868db3a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1378,7 +1378,10 @@ "$ref": "#/components/schemas/Category" }, "tags": { - "$ref": "#/components/schemas/Category/properties/parent/properties/tags" + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } } } }, @@ -1412,7 +1415,10 @@ "seq2": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerType/properties/seq1/items" + "type": "array", + "items": { + "type": "string" + } } } } @@ -1454,7 +1460,10 @@ "type": "object", "properties": { "name": { - "$ref": "#/components/schemas/Root/properties/item1/properties/name" + "type": "array", + "items": { + "type": "string" + } }, "value": { "type": "integer", 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 b3c5ec49c7e8..99edfe9f1ad7 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 @@ -740,8 +740,7 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("Category", ((OpenApiSchemaReference)requestSchema).Reference.Id); // Assert that $ref is used for nested Tags - // Todo: See https://github.com/microsoft/OpenAPI.NET/issues/2062 - // Assert.Equal("Tag", ((OpenApiSchemaReference)requestSchema.Properties["tags"].Items).Reference.Id); + Assert.Equal("Tag", ((OpenApiSchemaReference)requestSchema.Properties["tags"].Items).Reference.Id); // Assert that $ref is used for nested Parent Assert.Equal("Category", ((OpenApiSchemaReference)requestSchema.Properties["parent"]).Reference.Id); From bd036c4747a3f4ed2cd28c8b2aa091d26ba6faac Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 14 Aug 2025 08:26:05 -0700 Subject: [PATCH 2/5] Add test coverage from reported issues --- .../sample/Endpoints/MapSchemasEndpoints.cs | 62 ++++++ ...t_documentName=schemas-by-ref.verified.txt | 198 +++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 210 ++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 210 ++++++++++++++++++ .../OpenApiSchemaReferenceTransformerTests.cs | 191 ++++++++++++++++ 5 files changed, 871 insertions(+) diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index 1d47d6134957..23591e8efb18 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -41,6 +41,11 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild schemas.MapPatch("/json-patch-generic", (JsonPatchDocument patchDoc) => Results.NoContent()); schemas.MapGet("/custom-iresult", () => new CustomIResultImplementor { Content = "Hello world!" }) .Produces(200); + + // Tests for validating scenarios related to https://github.com/dotnet/aspnetcore/issues/61194 + schemas.MapPost("/config-with-generic-lists", (Config config) => Results.Ok(config)); + schemas.MapPost("/project-response", (ProjectResponse project) => Results.Ok(project)); + schemas.MapPost("/subscription", (Subscription subscription) => Results.Ok(subscription)); return endpointRouteBuilder; } @@ -111,4 +116,61 @@ public sealed class ChildObject public int Id { get; set; } public required ParentObject Parent { get; set; } } + + // Example types for GitHub issue 61194: Generic types referenced multiple times + public sealed class Config + { + public List Items1 { get; set; } = []; + public List Items2 { get; set; } = []; + } + + public sealed class ConfigItem + { + public int? Id { get; set; } + public string? Lang { get; set; } + public Dictionary? Words { get; set; } + public List? Break { get; set; } + public string? WillBeGood { get; set; } + } + + // Example types for GitHub issue 63054: Reused types across different hierarchies + public sealed class ProjectResponse + { + public required ProjectAddressResponse Address { get; init; } + public required ProjectBuilderResponse Builder { get; init; } + } + + public sealed class ProjectAddressResponse + { + public required CityResponse City { get; init; } + } + + public sealed class ProjectBuilderResponse + { + public required CityResponse City { get; init; } + } + + public sealed class CityResponse + { + public string Name { get; set; } = ""; + } + + // Example types for GitHub issue 63211: Nullable reference types + public sealed class Subscription + { + public required string Id { get; set; } + public required RefProfile PrimaryUser { get; set; } + public RefProfile? SecondaryUser { get; set; } + } + + public sealed class RefProfile + { + public required RefUser User { get; init; } + } + + public sealed class RefUser + { + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index ab8542c88535..86d6b730e372 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -570,6 +570,72 @@ } } } + }, + "/schemas-by-ref/config-with-generic-lists": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/project-response": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/subscription": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -633,6 +699,60 @@ } } }, + "CityResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "Config": { + "type": "object", + "properties": { + "items1": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + }, + "items2": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + } + } + }, + "ConfigItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "lang": { + "type": "string", + "nullable": true + }, + "words": { + "type": "object", + "nullable": true + }, + "break": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "willBeGood": { + "type": "string", + "nullable": true + } + } + }, "ContainerType": { "type": "object", "properties": { @@ -856,6 +976,66 @@ } } }, + "ProjectAddressResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectBuilderResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectResponse": { + "required": [ + "address", + "builder" + ], + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/ProjectAddressResponse" + }, + "builder": { + "$ref": "#/components/schemas/ProjectBuilderResponse" + } + } + }, + "RefProfile": { + "required": [ + "user" + ], + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/RefUser" + } + }, + "nullable": true + }, + "RefUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, "Root": { "type": "object", "properties": { @@ -930,6 +1110,24 @@ } } }, + "Subscription": { + "required": [ + "id", + "primaryUser" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "primaryUser": { + "$ref": "#/components/schemas/RefProfile" + }, + "secondaryUser": { + "$ref": "#/components/schemas/RefProfile" + } + } + }, "Tag": { "required": [ "name" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 2c0be542ed26..00e055a69541 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -570,6 +570,72 @@ } } } + }, + "/schemas-by-ref/config-with-generic-lists": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/project-response": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/subscription": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -633,6 +699,70 @@ } } }, + "CityResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "Config": { + "type": "object", + "properties": { + "items1": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + }, + "items2": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + } + } + }, + "ConfigItem": { + "type": "object", + "properties": { + "id": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "lang": { + "type": [ + "null", + "string" + ] + }, + "words": { + "type": [ + "null", + "object" + ] + }, + "break": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "willBeGood": { + "type": [ + "null", + "string" + ] + } + } + }, "ContainerType": { "type": "object", "properties": { @@ -856,6 +986,68 @@ } } }, + "ProjectAddressResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectBuilderResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectResponse": { + "required": [ + "address", + "builder" + ], + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/ProjectAddressResponse" + }, + "builder": { + "$ref": "#/components/schemas/ProjectBuilderResponse" + } + } + }, + "RefProfile": { + "required": [ + "user" + ], + "type": [ + "null", + "object" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/RefUser" + } + } + }, + "RefUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, "Root": { "type": "object", "properties": { @@ -930,6 +1122,24 @@ } } }, + "Subscription": { + "required": [ + "id", + "primaryUser" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "primaryUser": { + "$ref": "#/components/schemas/RefProfile" + }, + "secondaryUser": { + "$ref": "#/components/schemas/RefProfile" + } + } + }, "Tag": { "required": [ "name" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index bd67f868db3a..bd53a0a6e1d7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1096,6 +1096,72 @@ } } }, + "/schemas-by-ref/config-with-generic-lists": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/project-response": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/subscription": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/responses/200-add-xml": { "get": { "tags": [ @@ -1400,6 +1466,70 @@ } } }, + "CityResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "Config": { + "type": "object", + "properties": { + "items1": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + }, + "items2": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigItem" + } + } + } + }, + "ConfigItem": { + "type": "object", + "properties": { + "id": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "lang": { + "type": [ + "null", + "string" + ] + }, + "words": { + "type": [ + "null", + "object" + ] + }, + "break": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "willBeGood": { + "type": [ + "null", + "string" + ] + } + } + }, "ContainerType": { "type": "object", "properties": { @@ -1680,6 +1810,28 @@ }, "description": "The project that contains Todo items." }, + "ProjectAddressResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, + "ProjectBuilderResponse": { + "required": [ + "city" + ], + "type": "object", + "properties": { + "city": { + "$ref": "#/components/schemas/CityResponse" + } + } + }, "ProjectRecord": { "required": [ "name", @@ -1698,6 +1850,46 @@ }, "description": "The project that contains Todo items." }, + "ProjectResponse": { + "required": [ + "address", + "builder" + ], + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/ProjectAddressResponse" + }, + "builder": { + "$ref": "#/components/schemas/ProjectBuilderResponse" + } + } + }, + "RefProfile": { + "required": [ + "user" + ], + "type": [ + "null", + "object" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/RefUser" + } + } + }, + "RefUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, "Root": { "type": "object", "properties": { @@ -1772,6 +1964,24 @@ } } }, + "Subscription": { + "required": [ + "id", + "primaryUser" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "primaryUser": { + "$ref": "#/components/schemas/RefProfile" + }, + "secondaryUser": { + "$ref": "#/components/schemas/RefProfile" + } + } + }, "Tag": { "required": [ "name" 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 99edfe9f1ad7..67d4bf32160d 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 @@ -870,5 +870,196 @@ private class ChildObject public int Id { get; set; } public required ParentObject Parent { get; set; } } + + // Test for: https://github.com/dotnet/aspnetcore/issues/61194 + [Fact] + public async Task ResolveGenericTypesInListProperties() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/", (Config config) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/"]?.Operations?[HttpMethod.Post]; + var requestSchema = operation?.RequestBody?.Content?["application/json"].Schema; + + // Assert $ref used for top-level + Assert.NotNull(requestSchema); + Assert.Equal("Config", ((OpenApiSchemaReference)requestSchema).Reference.Id); + + // Get effective schema for Config + Assert.Equal(2, requestSchema.Properties!.Count); + + // Check Items1 and Items2 properties + var items1Schema = requestSchema.Properties!["items1"]; + var items2Schema = requestSchema.Properties!["items2"]; + + // Assert both are array types + Assert.Equal(JsonSchemaType.Array, items1Schema.Type); + Assert.Equal(JsonSchemaType.Array, items2Schema.Type); + + // Assert items reference the same ConfigItem schema + Assert.Equal("ConfigItem", ((OpenApiSchemaReference)items1Schema.Items!).Reference.Id); + Assert.Equal("ConfigItem", ((OpenApiSchemaReference)items2Schema.Items!).Reference.Id); + + // Verify the ConfigItem schema has proper content, not empty + var itemSchema = items1Schema.Items!; + Assert.True(itemSchema.Properties?.Count > 0, "ConfigItem schema should not be empty"); + Assert.Contains("id", itemSchema.Properties?.Keys ?? []); + Assert.Contains("lang", itemSchema.Properties?.Keys ?? []); + Assert.Contains("words", itemSchema.Properties?.Keys ?? []); + Assert.Contains("break", itemSchema.Properties?.Keys ?? []); + Assert.Contains("willBeGood", itemSchema.Properties?.Keys ?? []); + + Assert.Equal(["Config", "ConfigItem"], document.Components!.Schemas!.Keys.OrderBy(x => x)); + }); + } + + // Test for: https://github.com/dotnet/aspnetcore/issues/63054 + [Fact] + public async Task ResolveReusedTypesAcrossDifferentHierarchies() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/", (ProjectResponse project) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths?["/"].Operations?[HttpMethod.Post]; + var requestSchema = operation?.RequestBody?.Content?["application/json"].Schema; + + // Assert $ref used for top-level + Assert.NotNull(requestSchema); + Assert.Equal("ProjectResponse", ((OpenApiSchemaReference)requestSchema).Reference.Id); + + // Check Address property + var addressSchema = requestSchema.Properties!["address"]; + Assert.Equal("AddressResponse", ((OpenApiSchemaReference)addressSchema).Reference.Id); + + // Check Builder property + var builderSchema = requestSchema.Properties!["builder"]; + Assert.Equal("BuilderResponse", ((OpenApiSchemaReference)builderSchema).Reference.Id); + + // Verify CityResponse is properly referenced in Address + var cityInAddressSchema = addressSchema.Properties!["city"]; + Assert.Equal("CityResponse", ((OpenApiSchemaReference)cityInAddressSchema).Reference.Id); + + // Verify CityResponse is properly referenced in Builder + var cityInBuilderSchema = builderSchema.Properties!["city"]; + Assert.Equal("CityResponse", ((OpenApiSchemaReference)cityInBuilderSchema).Reference.Id); + + // Verify the CityResponse schema has proper content, not empty + var citySchema = cityInAddressSchema; + Assert.True(citySchema.Properties?.Count > 0, "CityResponse schema should not be empty"); + Assert.Contains("name", citySchema.Properties?.Keys ?? []); + + Assert.Equal(["AddressResponse", "BuilderResponse", "CityResponse", "ProjectResponse"], + document.Components!.Schemas!.Keys.OrderBy(x => x)); + }); + } + + // Test for: https://github.com/dotnet/aspnetcore/issues/63211 + [Fact] + public async Task ResolveNullableReferenceTypes() + { + // Arrange + var builder = CreateBuilder(); + + builder.MapPost("/", (Subscription subscription) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths?["/"].Operations?[HttpMethod.Post]; + var requestSchema = operation?.RequestBody?.Content?["application/json"].Schema; + + // Assert $ref used for top-level + Assert.NotNull(requestSchema); + Assert.Equal("Subscription", ((OpenApiSchemaReference)requestSchema).Reference.Id); + + // Check primaryUser property (required RefProfile) + var primaryUserSchema = requestSchema.Properties!["primaryUser"]; + Assert.Equal("RefProfile", ((OpenApiSchemaReference)primaryUserSchema).Reference.Id); + + // Check secondaryUser property (nullable RefProfile) + var secondaryUserSchema = requestSchema.Properties!["secondaryUser"]; + Assert.Equal("RefProfile", ((OpenApiSchemaReference)secondaryUserSchema).Reference.Id); + + // Verify the RefProfile schema has a User property that references RefUser + var userPropertySchema = primaryUserSchema.Properties!["user"]; + Assert.Equal("RefUser", ((OpenApiSchemaReference)userPropertySchema).Reference.Id); + + // Verify the RefUser schema has proper content, not empty + var userSchemaContent = userPropertySchema; + Assert.True(userSchemaContent.Properties?.Count > 0, "RefUser schema should not be empty"); + Assert.Contains("name", userSchemaContent.Properties?.Keys ?? []); + Assert.Contains("email", userSchemaContent.Properties?.Keys ?? []); + + // Both properties should reference the same RefProfile schema + Assert.Equal(((OpenApiSchemaReference)primaryUserSchema).Reference.Id, + ((OpenApiSchemaReference)secondaryUserSchema).Reference.Id); + + Assert.Equal(["RefProfile", "RefUser", "Subscription"], document.Components!.Schemas!.Keys.OrderBy(x => x)); + }); + } + + // Test models for issue 61194 + private class Config + { + public List Items1 { get; set; } = []; + public List Items2 { get; set; } = []; + } + + private class ConfigItem + { + public int? Id { get; set; } + public string? Lang { get; set; } + public Dictionary? Words { get; set; } + public List? Break { get; set; } + public string? WillBeGood { get; set; } + } + + // Test models for issue 63054 + private class ProjectResponse + { + public AddressResponse Address { get; init; } = new(); + public BuilderResponse Builder { get; init; } = new(); + } + + private class AddressResponse + { + public CityResponse City { get; init; } = new(); + } + + private class BuilderResponse + { + public CityResponse City { get; init; } = new(); + } + + private class CityResponse + { + public string Name { get; set; } = ""; + } + + // Test models for issue 63211 + public sealed class Subscription + { + public required string Id { get; set; } + public required RefProfile PrimaryUser { get; set; } + public RefProfile? SecondaryUser { get; set; } + } + + public sealed class RefProfile + { + public required RefUser User { get; init; } + } + + public sealed class RefUser + { + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + } } #nullable restore From fb50e52e91dee1fa11234afc6402540ee8b365c6 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 14 Aug 2025 08:59:31 -0700 Subject: [PATCH 3/5] Clean up implementation --- src/OpenApi/src/Services/OpenApiConstants.cs | 2 + .../Services/Schemas/OpenApiSchemaService.cs | 77 +++++++++++++------ 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index b32e5bb6e936..0bb51a9bbd32 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -15,6 +15,8 @@ internal static class OpenApiConstants internal const string RefId = "x-ref-id"; internal const string RefDescriptionAnnotation = "x-ref-description"; internal const string RefExampleAnnotation = "x-ref-example"; + internal const string RefKeyword = "$ref"; + internal const string RefPrefix = "#"; internal const string DefaultOpenApiResponseKey = "default"; // Since there's a finite set of HTTP methods that can be included in a given // OpenApiPaths, we can pre-allocate an array of these methods and use a direct diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index c8830a30c722..447dd24da9f3 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -462,26 +462,48 @@ private JsonNode CreateSchema(OpenApiSchemaKey key) return ResolveReferences(schema, schema); } - /// - /// Recursively resolves references within a JSON schema node. - /// private static JsonNode ResolveReferences(JsonNode node, JsonNode rootSchema) + { + return ResolveReferencesRecursive(node, rootSchema, []); + } + + private static JsonNode ResolveReferencesRecursive(JsonNode node, JsonNode rootSchema, HashSet visitedRefs) { if (node is JsonObject jsonObject) { - // Check if this is a reference object - if (jsonObject.TryGetPropertyValue("$ref", out var refNode) && + if (jsonObject.TryGetPropertyValue(OpenApiConstants.RefKeyword, out var refNode) && refNode is JsonValue refValue && refValue.TryGetValue(out var refString) && - refString.StartsWith('#')) + refString.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal)) { - // Resolve the reference path to the actual schema content - var resolvedNode = ResolveReference(refString, rootSchema); - if (resolvedNode != null) + if (visitedRefs.Contains(refString)) + { + return node; + } + + visitedRefs.Add(refString); + + try + { + // Resolve the reference path to the actual schema content + // to avoid relative references + var resolvedNode = ResolveReference(refString, rootSchema); + if (resolvedNode != null) + { + return resolvedNode.DeepClone(); + } + } + catch (InvalidOperationException) + { + // If resolution fails due to invalid path, return the original reference + // This maintains backward compatibility while preventing crashes + } + finally { - // Return a deep clone to avoid parent issues - return resolvedNode.DeepClone(); + // Remove from visited set to allow the same reference in different branches + visitedRefs.Remove(refString); } + // If resolution fails, return the original reference return node; } @@ -492,8 +514,7 @@ refNode is JsonValue refValue && { if (property.Value != null) { - var processedValue = ResolveReferences(property.Value, rootSchema); - // Clone the processed value to avoid parent issues + var processedValue = ResolveReferencesRecursive(property.Value, rootSchema, visitedRefs); newObject[property.Key] = processedValue?.DeepClone(); } else @@ -510,8 +531,7 @@ refNode is JsonValue refValue && { if (jsonArray[i] != null) { - var processedValue = ResolveReferences(jsonArray[i]!, rootSchema); - // Clone the processed value to avoid parent issues + var processedValue = ResolveReferencesRecursive(jsonArray[i]!, rootSchema, visitedRefs); newArray.Add(processedValue?.DeepClone()); } else @@ -522,16 +542,22 @@ refNode is JsonValue refValue && return newArray; } - // Return primitive values as-is + // Return non-$ref nodes as-is return node; } - /// - /// Resolves a JSON reference path (like "#/properties/parent/properties/tags") to the actual schema content. - /// private static JsonNode? ResolveReference(string refPath, JsonNode rootSchema) { - // Remove the leading "#" and split the path + if (string.IsNullOrWhiteSpace(refPath)) + { + throw new InvalidOperationException("Reference path cannot be null or empty."); + } + + if (!refPath.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Only fragment references (starting with '{OpenApiConstants.RefPrefix}') are supported. Found: {refPath}"); + } + var path = refPath.TrimStart('#').TrimStart('/'); if (string.IsNullOrEmpty(path)) { @@ -541,8 +567,9 @@ refNode is JsonValue refValue && var segments = path.Split('/'); var current = rootSchema; - foreach (var segment in segments) + for (var i = 0; i < segments.Length; i++) { + var segment = segments[i]; if (current is JsonObject currentObject) { if (currentObject.TryGetPropertyValue(segment, out var nextNode) && nextNode != null) @@ -551,14 +578,14 @@ refNode is JsonValue refValue && } else { - // Path not found - return null; + var partialPath = string.Join("/", segments.Take(i + 1)); + throw new InvalidOperationException($"Failed to resolve reference '{refPath}': path segment '{segment}' not found at '#{partialPath}'"); } } else { - // Cannot navigate further - return null; + var partialPath = string.Join("/", segments.Take(i)); + throw new InvalidOperationException($"Failed to resolve reference '{refPath}': cannot navigate beyond '#{partialPath}' - expected object but found {current?.GetType().Name ?? "null"}"); } } From 11fb89ff1e68ad5baeb7aa0333c52c36593131a7 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 14 Aug 2025 09:46:38 -0700 Subject: [PATCH 4/5] Fix up char-based checks --- src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 447dd24da9f3..936b570b2e78 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -558,7 +558,7 @@ refNode is JsonValue refValue && throw new InvalidOperationException($"Only fragment references (starting with '{OpenApiConstants.RefPrefix}') are supported. Found: {refPath}"); } - var path = refPath.TrimStart('#').TrimStart('/'); + var path = refPath.TrimStart('#', '/'); if (string.IsNullOrEmpty(path)) { return rootSchema; @@ -578,13 +578,13 @@ refNode is JsonValue refValue && } else { - var partialPath = string.Join("/", segments.Take(i + 1)); + var partialPath = string.Join('/', segments.Take(i + 1)); throw new InvalidOperationException($"Failed to resolve reference '{refPath}': path segment '{segment}' not found at '#{partialPath}'"); } } else { - var partialPath = string.Join("/", segments.Take(i)); + var partialPath = string.Join('/', segments.Take(i)); throw new InvalidOperationException($"Failed to resolve reference '{refPath}': cannot navigate beyond '#{partialPath}' - expected object but found {current?.GetType().Name ?? "null"}"); } } From 8a98b9093e6bf0398615592ff881ee44e92b4497 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 14 Aug 2025 14:43:26 -0700 Subject: [PATCH 5/5] Remove visitedRefs and rely on STJ resolution --- .../Services/Schemas/OpenApiSchemaService.cs | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 936b570b2e78..d35ab8bb449e 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -464,10 +464,10 @@ private JsonNode CreateSchema(OpenApiSchemaKey key) private static JsonNode ResolveReferences(JsonNode node, JsonNode rootSchema) { - return ResolveReferencesRecursive(node, rootSchema, []); + return ResolveReferencesRecursive(node, rootSchema); } - private static JsonNode ResolveReferencesRecursive(JsonNode node, JsonNode rootSchema, HashSet visitedRefs) + private static JsonNode ResolveReferencesRecursive(JsonNode node, JsonNode rootSchema) { if (node is JsonObject jsonObject) { @@ -476,13 +476,6 @@ refNode is JsonValue refValue && refValue.TryGetValue(out var refString) && refString.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal)) { - if (visitedRefs.Contains(refString)) - { - return node; - } - - visitedRefs.Add(refString); - try { // Resolve the reference path to the actual schema content @@ -498,11 +491,6 @@ refNode is JsonValue refValue && // If resolution fails due to invalid path, return the original reference // This maintains backward compatibility while preventing crashes } - finally - { - // Remove from visited set to allow the same reference in different branches - visitedRefs.Remove(refString); - } // If resolution fails, return the original reference return node; @@ -514,7 +502,7 @@ refNode is JsonValue refValue && { if (property.Value != null) { - var processedValue = ResolveReferencesRecursive(property.Value, rootSchema, visitedRefs); + var processedValue = ResolveReferencesRecursive(property.Value, rootSchema); newObject[property.Key] = processedValue?.DeepClone(); } else @@ -531,7 +519,7 @@ refNode is JsonValue refValue && { if (jsonArray[i] != null) { - var processedValue = ResolveReferencesRecursive(jsonArray[i]!, rootSchema, visitedRefs); + var processedValue = ResolveReferencesRecursive(jsonArray[i]!, rootSchema); newArray.Add(processedValue?.DeepClone()); } else