From 26d9e4f45cbf5c63e1912c7be359c3a9570edc50 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Tue, 2 Sep 2025 10:37:06 +0200 Subject: [PATCH 1/6] Add circular schema example --- .../Endpoints/MapCircularSchemaEndpoints.cs | 43 +++++++++++++++++++ src/OpenApi/sample/Program.cs | 5 +++ .../sample/Properties/launchSettings.json | 2 +- 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs diff --git a/src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs b/src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs new file mode 100644 index 000000000000..26d124870469 --- /dev/null +++ b/src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Sample.Endpoints; + +public static class CircularEndpointsExtensions +{ + public static IEndpointRouteBuilder MapCircularEndpoints1(this IEndpointRouteBuilder endpointRouteBuilder) + { + var circularSchemaRoutes = endpointRouteBuilder.MapGroup("circular1") + .WithGroupName("circular1"); + + circularSchemaRoutes.MapGet("/model", () => TypedResults.Ok(new CircularModel1())); + + return circularSchemaRoutes; + } + public static IEndpointRouteBuilder MapCircularEndpoints2(this IEndpointRouteBuilder endpointRouteBuilder) + { + var circularSchemaRoutes = endpointRouteBuilder.MapGroup("circular2") + .WithGroupName("circular2"); + + circularSchemaRoutes.MapGet("/model", () => TypedResults.Ok(new CircularModel2())); + + return circularSchemaRoutes; + } + + public class CircularModel1 + { + public ReferencedModel Referenced { get; set; } = null!; + public CircularModel1 Self { get; set; } = null!; + } + + public class CircularModel2 + { + public CircularModel2 Self { get; set; } = null!; + public ReferencedModel Referenced { get; set; } = null!; + } + + public class ReferencedModel + { + public int Id { get; set; } + } +} diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 11fd830b1234..d73c3f6b68bf 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text.Json.Serialization; +using Sample.Endpoints; using Sample.Transformers; var builder = WebApplication.CreateBuilder(args); @@ -35,6 +36,8 @@ return Task.CompletedTask; }); }); +builder.Services.AddOpenApi("circular1"); +builder.Services.AddOpenApi("circular2"); builder.Services.AddOpenApi("controllers"); builder.Services.AddOpenApi("responses"); builder.Services.AddOpenApi("forms"); @@ -59,6 +62,8 @@ app.MapSwaggerUi(); } +app.MapCircularEndpoints1(); +app.MapCircularEndpoints2(); app.MapFormEndpoints(); app.MapV1Endpoints(); app.MapV2Endpoints(); diff --git a/src/OpenApi/sample/Properties/launchSettings.json b/src/OpenApi/sample/Properties/launchSettings.json index 1b535d687afb..56a3cd7c78b1 100644 --- a/src/OpenApi/sample/Properties/launchSettings.json +++ b/src/OpenApi/sample/Properties/launchSettings.json @@ -21,7 +21,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://sample.dev.localhost:7174;http://sample.dev.localhost:5051", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From e6d08cc762a266754431f2ddff98b0caa396fff8 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Tue, 2 Sep 2025 18:36:40 +0200 Subject: [PATCH 2/6] Fix empty schema in certain orderings of schemas. This is fixed by not adding/registering a schema when it contains a reference, as it is a incomplete/unresolved schema --- .../Services/Schemas/OpenApiSchemaService.cs | 2 +- ...ifyOpenApiDocumentIsInvariant.verified.txt | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 12b0c1ed996c..931c1a80c546 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -337,7 +337,7 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen if (schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var schemaId) && schemaId is string schemaIdString) { - return document.AddOpenApiSchemaByReference(schemaIdString, schema); + return new OpenApiSchemaReference(schemaIdString, document); } var relativeSchemaId = $"#/components/schemas/{rootSchemaId}{refIdString.Replace("#", string.Empty)}"; return new OpenApiSchemaReference(relativeSchemaId, document); 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 eec2cfe16702..1faee0c349ca 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 @@ -11,6 +11,44 @@ } ], "paths": { + "/circular1/model": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CircularModel1" + } + } + } + } + } + } + }, + "/circular2/model": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CircularModel2" + } + } + } + } + } + } + }, "/forms/form-file": { "post": { "tags": [ @@ -1592,6 +1630,28 @@ } } }, + "CircularModel1": { + "type": "object", + "properties": { + "referenced": { + "$ref": "#/components/schemas/ReferencedModel" + }, + "self": { + "$ref": "#/components/schemas/CircularModel1" + } + } + }, + "CircularModel2": { + "type": "object", + "properties": { + "self": { + "$ref": "#/components/schemas/CircularModel2" + }, + "referenced": { + "$ref": "#/components/schemas/ReferencedModel" + } + } + }, "CityResponse": { "type": "object", "properties": { @@ -2210,6 +2270,15 @@ } } }, + "ReferencedModel": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + } + } + }, "RefProfile": { "required": [ "user" From d3d59820a74678e1bdf28cc9563fcea569050b3e Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Tue, 2 Sep 2025 20:12:08 +0200 Subject: [PATCH 3/6] Revert launchsettings.json --- src/OpenApi/sample/Properties/launchSettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/sample/Properties/launchSettings.json b/src/OpenApi/sample/Properties/launchSettings.json index 56a3cd7c78b1..1b535d687afb 100644 --- a/src/OpenApi/sample/Properties/launchSettings.json +++ b/src/OpenApi/sample/Properties/launchSettings.json @@ -21,7 +21,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, "applicationUrl": "https://sample.dev.localhost:7174;http://sample.dev.localhost:5051", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From f159bede2b9e4925dc06e7fd8cbf302ce3d8d20f Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 4 Sep 2025 11:36:05 +0200 Subject: [PATCH 4/6] Add unit tests for circular references and remove it from the sample --- .../Endpoints/MapCircularSchemaEndpoints.cs | 43 ------ src/OpenApi/sample/Program.cs | 5 - ...ifyOpenApiDocumentIsInvariant.verified.txt | 69 --------- .../OpenApiSchemaService.SchemaReferences.cs | 138 ++++++++++++++++++ 4 files changed, 138 insertions(+), 117 deletions(-) delete mode 100644 src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs diff --git a/src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs b/src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs deleted file mode 100644 index 26d124870469..000000000000 --- a/src/OpenApi/sample/Endpoints/MapCircularSchemaEndpoints.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Sample.Endpoints; - -public static class CircularEndpointsExtensions -{ - public static IEndpointRouteBuilder MapCircularEndpoints1(this IEndpointRouteBuilder endpointRouteBuilder) - { - var circularSchemaRoutes = endpointRouteBuilder.MapGroup("circular1") - .WithGroupName("circular1"); - - circularSchemaRoutes.MapGet("/model", () => TypedResults.Ok(new CircularModel1())); - - return circularSchemaRoutes; - } - public static IEndpointRouteBuilder MapCircularEndpoints2(this IEndpointRouteBuilder endpointRouteBuilder) - { - var circularSchemaRoutes = endpointRouteBuilder.MapGroup("circular2") - .WithGroupName("circular2"); - - circularSchemaRoutes.MapGet("/model", () => TypedResults.Ok(new CircularModel2())); - - return circularSchemaRoutes; - } - - public class CircularModel1 - { - public ReferencedModel Referenced { get; set; } = null!; - public CircularModel1 Self { get; set; } = null!; - } - - public class CircularModel2 - { - public CircularModel2 Self { get; set; } = null!; - public ReferencedModel Referenced { get; set; } = null!; - } - - public class ReferencedModel - { - public int Id { get; set; } - } -} diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index d73c3f6b68bf..11fd830b1234 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Text.Json.Serialization; -using Sample.Endpoints; using Sample.Transformers; var builder = WebApplication.CreateBuilder(args); @@ -36,8 +35,6 @@ return Task.CompletedTask; }); }); -builder.Services.AddOpenApi("circular1"); -builder.Services.AddOpenApi("circular2"); builder.Services.AddOpenApi("controllers"); builder.Services.AddOpenApi("responses"); builder.Services.AddOpenApi("forms"); @@ -62,8 +59,6 @@ app.MapSwaggerUi(); } -app.MapCircularEndpoints1(); -app.MapCircularEndpoints2(); app.MapFormEndpoints(); app.MapV1Endpoints(); app.MapV2Endpoints(); 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 1faee0c349ca..eec2cfe16702 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 @@ -11,44 +11,6 @@ } ], "paths": { - "/circular1/model": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CircularModel1" - } - } - } - } - } - } - }, - "/circular2/model": { - "get": { - "tags": [ - "Sample" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CircularModel2" - } - } - } - } - } - } - }, "/forms/form-file": { "post": { "tags": [ @@ -1630,28 +1592,6 @@ } } }, - "CircularModel1": { - "type": "object", - "properties": { - "referenced": { - "$ref": "#/components/schemas/ReferencedModel" - }, - "self": { - "$ref": "#/components/schemas/CircularModel1" - } - } - }, - "CircularModel2": { - "type": "object", - "properties": { - "self": { - "$ref": "#/components/schemas/CircularModel2" - }, - "referenced": { - "$ref": "#/components/schemas/ReferencedModel" - } - } - }, "CityResponse": { "type": "object", "properties": { @@ -2270,15 +2210,6 @@ } } }, - "ReferencedModel": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - } - } - }, "RefProfile": { "required": [ "user" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs new file mode 100644 index 000000000000..137f1f35cffa --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task SchemaReferences_HandlesCircularReferencesRegardlessOfPropertyOrder_SelfFirst() + { + var builder = CreateBuilder(); + builder.MapPost("/", (DirectCircularModelSelfFirst dto) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Components.Schemas["DirectCircularModelSelfFirst"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("self", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelSelfFirst", reference.Reference.ReferenceV3); + }, + property => + { + Assert.Equal("referenced", property.Key); + var reference = Assert.IsType(property.Value); + }); + + // Verify that it does not result in an empty schema for a referenced schema + var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotEmpty(referencedSchema.Properties); + var idProperty = Assert.Single(referencedSchema.Properties); + Assert.Equal("id", idProperty.Key); + var idPropertySchema = Assert.IsType(idProperty.Value); + Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); + }); + } + + [Fact] + public async Task SchemaReferences_HandlesCircularReferencesRegardlessOfPropertyOrder_SelfLast() + { + var builder = CreateBuilder(); + builder.MapPost("/", (DirectCircularModelSelfLast dto) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Components.Schemas["DirectCircularModelSelfLast"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("referenced", property.Key); + var reference = Assert.IsType(property.Value); + }, + property => + { + Assert.Equal("self", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelSelfLast", reference.Reference.ReferenceV3); + }); + + // Verify that it does not result in an empty schema for a referenced schema + var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotEmpty(referencedSchema.Properties); + var idProperty = Assert.Single(referencedSchema.Properties); + Assert.Equal("id", idProperty.Key); + var idPropertySchema = Assert.IsType(idProperty.Value); + Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); + }); + } + + [Fact] + public async Task SchemaReferences_HandlesCircularReferencesRegardlessOfPropertyOrder_MultipleSelf() + { + var builder = CreateBuilder(); + builder.MapPost("/", (DirectCircularModelMultiple dto) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Components.Schemas["DirectCircularModelMultiple"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("selfFirst", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelMultiple", reference.Reference.ReferenceV3); + }, + property => + { + Assert.Equal("referenced", property.Key); + var reference = Assert.IsType(property.Value); + }, + property => + { + Assert.Equal("selfLast", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelMultiple", reference.Reference.ReferenceV3); + }); + + // Verify that it does not result in an empty schema for a referenced schema + var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotEmpty(referencedSchema.Properties); + var idProperty = Assert.Single(referencedSchema.Properties); + Assert.Equal("id", idProperty.Key); + var idPropertySchema = Assert.IsType(idProperty.Value); + Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); + }); + } + + private class DirectCircularModelSelfFirst + { + public DirectCircularModelSelfFirst Self { get; set; } = null!; + public ReferencedModel Referenced { get; set; } = null!; + } + + private class DirectCircularModelSelfLast + { + public ReferencedModel Referenced { get; set; } = null!; + public DirectCircularModelSelfLast Self { get; set; } = null!; + } + + private class DirectCircularModelMultiple + { + public DirectCircularModelMultiple SelfFirst { get; set; } = null!; + public ReferencedModel Referenced { get; set; } = null!; + public DirectCircularModelMultiple SelfLast { get; set; } = null!; + } + + private class ReferencedModel + { + public int Id { get; set; } + } +} From 1ef6a404fe88363ab1aa9672a59221a8679bb5e0 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 4 Sep 2025 23:38:55 +0200 Subject: [PATCH 5/6] Move tests to OpenApiSchemaReferenceTransformerTests --- .../OpenApiSchemaService.SchemaReferences.cs | 138 ------------------ .../OpenApiSchemaReferenceTransformerTests.cs | 134 +++++++++++++++++ 2 files changed, 134 insertions(+), 138 deletions(-) delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs deleted file mode 100644 index 137f1f35cffa..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.SchemaReferences.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; - -public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase -{ - [Fact] - public async Task SchemaReferences_HandlesCircularReferencesRegardlessOfPropertyOrder_SelfFirst() - { - var builder = CreateBuilder(); - builder.MapPost("/", (DirectCircularModelSelfFirst dto) => { }); - - // Assert - await VerifyOpenApiDocument(builder, document => - { - var schema = document.Components.Schemas["DirectCircularModelSelfFirst"]; - Assert.Equal(JsonSchemaType.Object, schema.Type); - Assert.Collection(schema.Properties, - property => - { - Assert.Equal("self", property.Key); - var reference = Assert.IsType(property.Value); - Assert.Equal("#/components/schemas/DirectCircularModelSelfFirst", reference.Reference.ReferenceV3); - }, - property => - { - Assert.Equal("referenced", property.Key); - var reference = Assert.IsType(property.Value); - }); - - // Verify that it does not result in an empty schema for a referenced schema - var referencedSchema = document.Components.Schemas["ReferencedModel"]; - Assert.NotEmpty(referencedSchema.Properties); - var idProperty = Assert.Single(referencedSchema.Properties); - Assert.Equal("id", idProperty.Key); - var idPropertySchema = Assert.IsType(idProperty.Value); - Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); - }); - } - - [Fact] - public async Task SchemaReferences_HandlesCircularReferencesRegardlessOfPropertyOrder_SelfLast() - { - var builder = CreateBuilder(); - builder.MapPost("/", (DirectCircularModelSelfLast dto) => { }); - - await VerifyOpenApiDocument(builder, document => - { - var schema = document.Components.Schemas["DirectCircularModelSelfLast"]; - Assert.Equal(JsonSchemaType.Object, schema.Type); - Assert.Collection(schema.Properties, - property => - { - Assert.Equal("referenced", property.Key); - var reference = Assert.IsType(property.Value); - }, - property => - { - Assert.Equal("self", property.Key); - var reference = Assert.IsType(property.Value); - Assert.Equal("#/components/schemas/DirectCircularModelSelfLast", reference.Reference.ReferenceV3); - }); - - // Verify that it does not result in an empty schema for a referenced schema - var referencedSchema = document.Components.Schemas["ReferencedModel"]; - Assert.NotEmpty(referencedSchema.Properties); - var idProperty = Assert.Single(referencedSchema.Properties); - Assert.Equal("id", idProperty.Key); - var idPropertySchema = Assert.IsType(idProperty.Value); - Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); - }); - } - - [Fact] - public async Task SchemaReferences_HandlesCircularReferencesRegardlessOfPropertyOrder_MultipleSelf() - { - var builder = CreateBuilder(); - builder.MapPost("/", (DirectCircularModelMultiple dto) => { }); - - await VerifyOpenApiDocument(builder, document => - { - var schema = document.Components.Schemas["DirectCircularModelMultiple"]; - Assert.Equal(JsonSchemaType.Object, schema.Type); - Assert.Collection(schema.Properties, - property => - { - Assert.Equal("selfFirst", property.Key); - var reference = Assert.IsType(property.Value); - Assert.Equal("#/components/schemas/DirectCircularModelMultiple", reference.Reference.ReferenceV3); - }, - property => - { - Assert.Equal("referenced", property.Key); - var reference = Assert.IsType(property.Value); - }, - property => - { - Assert.Equal("selfLast", property.Key); - var reference = Assert.IsType(property.Value); - Assert.Equal("#/components/schemas/DirectCircularModelMultiple", reference.Reference.ReferenceV3); - }); - - // Verify that it does not result in an empty schema for a referenced schema - var referencedSchema = document.Components.Schemas["ReferencedModel"]; - Assert.NotEmpty(referencedSchema.Properties); - var idProperty = Assert.Single(referencedSchema.Properties); - Assert.Equal("id", idProperty.Key); - var idPropertySchema = Assert.IsType(idProperty.Value); - Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); - }); - } - - private class DirectCircularModelSelfFirst - { - public DirectCircularModelSelfFirst Self { get; set; } = null!; - public ReferencedModel Referenced { get; set; } = null!; - } - - private class DirectCircularModelSelfLast - { - public ReferencedModel Referenced { get; set; } = null!; - public DirectCircularModelSelfLast Self { get; set; } = null!; - } - - private class DirectCircularModelMultiple - { - public DirectCircularModelMultiple SelfFirst { get; set; } = null!; - public ReferencedModel Referenced { get; set; } = null!; - public DirectCircularModelMultiple SelfLast { get; set; } = null!; - } - - private class ReferencedModel - { - public int Id { get; set; } - } -} 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 1d49c03970b5..9c62c0a3e9ab 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 @@ -1004,6 +1004,115 @@ await VerifyOpenApiDocument(builder, document => }); } + // Test for: https://github.com/dotnet/aspnetcore/issues/63503 + [Fact] + public async Task HandlesCircularReferencesRegardlessOfPropertyOrder_SelfFirst() + { + var builder = CreateBuilder(); + builder.MapPost("/", (DirectCircularModelSelfFirst dto) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Components.Schemas["DirectCircularModelSelfFirst"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("self", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelSelfFirst", reference.Reference.ReferenceV3); + }, + property => + { + Assert.Equal("referenced", property.Key); + var reference = Assert.IsType(property.Value); + }); + + // Verify that it does not result in an empty schema for a referenced schema + var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotEmpty(referencedSchema.Properties); + var idProperty = Assert.Single(referencedSchema.Properties); + Assert.Equal("id", idProperty.Key); + var idPropertySchema = Assert.IsType(idProperty.Value); + Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); + }); + } + + // Test for: https://github.com/dotnet/aspnetcore/issues/63503 + [Fact] + public async Task HandlesCircularReferencesRegardlessOfPropertyOrder_SelfLast() + { + var builder = CreateBuilder(); + builder.MapPost("/", (DirectCircularModelSelfLast dto) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Components.Schemas["DirectCircularModelSelfLast"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("referenced", property.Key); + var reference = Assert.IsType(property.Value); + }, + property => + { + Assert.Equal("self", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelSelfLast", reference.Reference.ReferenceV3); + }); + + // Verify that it does not result in an empty schema for a referenced schema + var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotEmpty(referencedSchema.Properties); + var idProperty = Assert.Single(referencedSchema.Properties); + Assert.Equal("id", idProperty.Key); + var idPropertySchema = Assert.IsType(idProperty.Value); + Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); + }); + } + + // Test for: https://github.com/dotnet/aspnetcore/issues/63503 + [Fact] + public async Task HandlesCircularReferencesRegardlessOfPropertyOrder_MultipleSelf() + { + var builder = CreateBuilder(); + builder.MapPost("/", (DirectCircularModelMultiple dto) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Components.Schemas["DirectCircularModelMultiple"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("selfFirst", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelMultiple", reference.Reference.ReferenceV3); + }, + property => + { + Assert.Equal("referenced", property.Key); + var reference = Assert.IsType(property.Value); + }, + property => + { + Assert.Equal("selfLast", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("#/components/schemas/DirectCircularModelMultiple", reference.Reference.ReferenceV3); + }); + + // Verify that it does not result in an empty schema for a referenced schema + var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotEmpty(referencedSchema.Properties); + var idProperty = Assert.Single(referencedSchema.Properties); + Assert.Equal("id", idProperty.Key); + var idPropertySchema = Assert.IsType(idProperty.Value); + Assert.Equal(JsonSchemaType.Integer, idPropertySchema.Type); + }); + } + // Test models for issue 61194 private class Config { @@ -1060,5 +1169,30 @@ public sealed class RefUser public string Name { get; set; } = ""; public string Email { get; set; } = ""; } + + // Test models for issue 63503 + private class DirectCircularModelSelfFirst + { + public DirectCircularModelSelfFirst Self { get; set; } = null!; + public ReferencedModel Referenced { get; set; } = null!; + } + + private class DirectCircularModelSelfLast + { + public ReferencedModel Referenced { get; set; } = null!; + public DirectCircularModelSelfLast Self { get; set; } = null!; + } + + private class DirectCircularModelMultiple + { + public DirectCircularModelMultiple SelfFirst { get; set; } = null!; + public ReferencedModel Referenced { get; set; } = null!; + public DirectCircularModelMultiple SelfLast { get; set; } = null!; + } + + private class ReferencedModel + { + public int Id { get; set; } + } } #nullable restore From 426664f383ed17afa1d9a4886ce428dd0fcbf5e1 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Fri, 5 Sep 2025 00:11:23 +0200 Subject: [PATCH 6/6] Fix NRT warnings --- .../OpenApiSchemaReferenceTransformerTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) 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 9c62c0a3e9ab..2979e5198444 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 @@ -1014,8 +1014,10 @@ public async Task HandlesCircularReferencesRegardlessOfPropertyOrder_SelfFirst() // Assert await VerifyOpenApiDocument(builder, document => { + Assert.NotNull(document.Components?.Schemas); var schema = document.Components.Schemas["DirectCircularModelSelfFirst"]; Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.NotNull(schema.Properties); Assert.Collection(schema.Properties, property => { @@ -1031,6 +1033,7 @@ await VerifyOpenApiDocument(builder, document => // Verify that it does not result in an empty schema for a referenced schema var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotNull(referencedSchema.Properties); Assert.NotEmpty(referencedSchema.Properties); var idProperty = Assert.Single(referencedSchema.Properties); Assert.Equal("id", idProperty.Key); @@ -1048,8 +1051,10 @@ public async Task HandlesCircularReferencesRegardlessOfPropertyOrder_SelfLast() await VerifyOpenApiDocument(builder, document => { + Assert.NotNull(document.Components?.Schemas); var schema = document.Components.Schemas["DirectCircularModelSelfLast"]; Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.NotNull(schema.Properties); Assert.Collection(schema.Properties, property => { @@ -1065,6 +1070,7 @@ await VerifyOpenApiDocument(builder, document => // Verify that it does not result in an empty schema for a referenced schema var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotNull(referencedSchema.Properties); Assert.NotEmpty(referencedSchema.Properties); var idProperty = Assert.Single(referencedSchema.Properties); Assert.Equal("id", idProperty.Key); @@ -1082,8 +1088,10 @@ public async Task HandlesCircularReferencesRegardlessOfPropertyOrder_MultipleSel await VerifyOpenApiDocument(builder, document => { + Assert.NotNull(document.Components?.Schemas); var schema = document.Components.Schemas["DirectCircularModelMultiple"]; Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.NotNull(schema.Properties); Assert.Collection(schema.Properties, property => { @@ -1105,6 +1113,7 @@ await VerifyOpenApiDocument(builder, document => // Verify that it does not result in an empty schema for a referenced schema var referencedSchema = document.Components.Schemas["ReferencedModel"]; + Assert.NotNull(referencedSchema.Properties); Assert.NotEmpty(referencedSchema.Properties); var idProperty = Assert.Single(referencedSchema.Properties); Assert.Equal("id", idProperty.Key);