From 6a27f1a7366a448e482ecc43e8c07c6474495c81 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 7 Mar 2025 21:20:19 -0800 Subject: [PATCH] Add more schema tests --- .../sample/Endpoints/MapSchemasEndoints.cs | 65 +++++ ...t_documentName=schemas-by-ref.verified.txt | 261 ++++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 261 ++++++++++++++++++ .../OpenApiSchemaReferenceTransformerTests.cs | 156 +++++++++++ 4 files changed, 743 insertions(+) diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndoints.cs index 8a4ebe21ab83..ee1840d07164 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndoints.cs @@ -30,7 +30,72 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild schemas.MapPost("/shape", (Shape shape) => { }); schemas.MapPost("/weatherforecastbase", (WeatherForecastBase forecast) => { }); schemas.MapPost("/person", (Person person) => { }); + schemas.MapPost("/category", (Category category) => { }); + schemas.MapPost("/container", (ContainerType container) => { }); + schemas.MapPost("/root", (Root root) => { }); + schemas.MapPost("/location", (LocationContainer location) => { }); + schemas.MapPost("/parent", (ParentObject parent) => Results.Ok(parent)); + schemas.MapPost("/child", (ChildObject child) => Results.Ok(child)); return endpointRouteBuilder; } + + 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/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 740782bd1fec..d512ac884eb9 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 @@ -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,113 @@ } } }, + "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": { } + } + }, + "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": { } + } + } + }, + "Item": { + "type": "object", + "properties": { + "name": { }, + "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 +693,17 @@ } } }, + "Root": { + "type": "object", + "properties": { + "item1": { + "$ref": "#/components/schemas/Item" + }, + "item2": { + "$ref": "#/components/schemas/Item" + } + } + }, "Shape": { "required": [ "$type" @@ -517,6 +767,17 @@ } } }, + "Tag": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "Triangle": { "type": "object", "properties": { 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 b750396361c0..705e2527a13d 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 @@ -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,113 @@ } } }, + "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": { } + } + }, + "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": { } + } + } + }, + "Item": { + "type": "object", + "properties": { + "name": { }, + "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 +693,17 @@ } } }, + "Root": { + "type": "object", + "properties": { + "item1": { + "$ref": "#/components/schemas/Item" + }, + "item2": { + "$ref": "#/components/schemas/Item" + } + } + }, "Shape": { "required": [ "$type" @@ -517,6 +767,17 @@ } } }, + "Tag": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "Triangle": { "type": "object", "properties": { 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 0e112e963664..3370ff749a32 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 @@ -680,6 +680,162 @@ 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", ((OpenApiSchemaReference)requestSchema).Reference.Id); + + // Get effective schema for ContainerType + Assert.Equal(2, requestSchema.Properties.Count); + + // Check Seq1 and Seq2 properties + var seq1Schema = requestSchema.Properties["seq1"]; + var seq2Schema = requestSchema.Properties["seq2"]; + + // Assert both are array types + Assert.Equal(JsonSchemaType.Array | JsonSchemaType.Null, seq1Schema.Type); + Assert.Equal(JsonSchemaType.Array | JsonSchemaType.Null, seq2Schema.Type); + + // Assert items are arrays of strings + Assert.Equal(JsonSchemaType.Array, seq1Schema.Items.Type); + // Todo: See https://github.com/microsoft/OpenAPI.NET/issues/2062 + // Assert.Equal(JsonSchemaType.Array, seq2Schema.Items.Type); + + // Since both Seq1 and Seq2 are the same type (List>), + // they should reference the same schema structure + // Todo: See https://github.com/microsoft/OpenAPI.NET/issues/2062 + // Assert.Equal(seq1Schema.Items.Type, seq2Schema.Items.Type); + + // Verify the inner arrays contain strings + Assert.Equal(JsonSchemaType.String, seq1Schema.Items.Items.Type); + // Todo: See https://github.com/microsoft/OpenAPI.NET/issues/2062 + // Assert.Equal(JsonSchemaType.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", ((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 that $ref is used for nested Parent + Assert.Equal("Category", ((OpenApiSchemaReference)requestSchema.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", ((OpenApiSchemaReference)requestSchema).Reference.Id); + + // Assert that $ref is used for nested Children + Assert.Equal("ChildObject", ((OpenApiSchemaReference)requestSchema.Properties["children"].Items).Reference.Id); + + // Assert that $ref is used for nested Parent + var childSchema = requestSchema.Properties["children"].Items; + Assert.Equal("ParentObject", ((OpenApiSchemaReference)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", ((OpenApiSchemaReference)requestSchema.Items).Reference.Id); + var parentSchema = requestSchema.Items; + Assert.Equal("ChildObject", ((OpenApiSchemaReference)parentSchema.Properties["children"].Items).Reference.Id); + + childSchema = parentSchema.Properties["children"].Items; + Assert.Equal("ParentObject", ((OpenApiSchemaReference)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", ((OpenApiSchemaReference)requestSchema.AdditionalProperties).Reference.Id); + parentSchema = requestSchema.AdditionalProperties; + Assert.Equal("ChildObject", ((OpenApiSchemaReference)parentSchema.Properties["children"].Items).Reference.Id); + + childSchema = parentSchema.Properties["children"].Items; + Assert.Equal("ParentObject", ((OpenApiSchemaReference)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!;