diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 945326b1d5b1..c1036ffad128 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Reflection; @@ -175,13 +176,17 @@ internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValu return; } + var schemaAttribute = schema.WillBeComponentized() + ? OpenApiConstants.RefDefaultAnnotation + : OpenApiSchemaKeywords.DefaultKeyword; + if (defaultValue is null) { - schema[OpenApiSchemaKeywords.DefaultKeyword] = null; + schema[schemaAttribute] = null; } else { - schema[OpenApiSchemaKeywords.DefaultKeyword] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo); + schema[schemaAttribute] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo); } } @@ -429,6 +434,33 @@ internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExpo } } + /// + /// Determines whether the specified JSON schema will be moved into the components section. + /// + /// The produced by the underlying schema generator. + /// if the schema will be componentized; otherwise, . + internal static bool WillBeComponentized(this JsonNode schema) + => schema.WillBeComponentized(out _); + + /// + /// Determines whether the specified JSON schema node contains a componentized schema identifier. + /// + /// The JSON schema node to inspect for a componentized schema identifier. + /// When this method returns , contains the schema identifier found in the node; otherwise, + /// . + /// if the schema will be componentized; otherwise, . + internal static bool WillBeComponentized(this JsonNode schema, [NotNullWhen(true)] out string? schemaId) + { + if (schema[OpenApiConstants.SchemaId] is JsonNode schemaIdNode + && schemaIdNode.GetValueKind() == JsonValueKind.String) + { + schemaId = schemaIdNode.GetValue(); + return true; + } + schemaId = null; + return false; + } + /// /// Returns if the current type is a non-abstract base class that is not defined as its /// own derived type. @@ -458,7 +490,7 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString(); } } - if (schema[OpenApiConstants.SchemaId] is not null && + if (schema.WillBeComponentized() && propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema()) { schema[OpenApiConstants.NullableProperty] = true; @@ -472,7 +504,7 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope /// The produced by the underlying schema generator. internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema) { - if (schema[OpenApiConstants.SchemaId] is not null && + if (schema.WillBeComponentized() && schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray) { for (var i = typeArray.Count - 1; i >= 0; i--) diff --git a/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs b/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs index c09bd50dc67b..9a73be8e64fb 100644 --- a/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs @@ -17,25 +17,27 @@ internal static class OpenApiDocumentExtensions /// An with a reference to the stored schema. public static IOpenApiSchema AddOpenApiSchemaByReference(this OpenApiDocument document, string schemaId, IOpenApiSchema schema) { - document.Components ??= new(); - document.Components.Schemas ??= new Dictionary(); - document.Components.Schemas[schemaId] = schema; + // Make sure the document has a workspace, + // AddComponent will add it to the workspace when adding the component. document.Workspace ??= new(); - var location = document.BaseUri + "/components/schemas/" + schemaId; - document.Workspace.RegisterComponentForDocument(document, schema, location); + // AddComponent will only add the schema if it doesn't already exist. + document.AddComponent(schemaId, schema); object? description = null; object? example = null; + object? defaultAnnotation = null; if (schema is OpenApiSchema actualSchema) { actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefDescriptionAnnotation, out description); actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefExampleAnnotation, out example); + actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefDefaultAnnotation, out defaultAnnotation); } return new OpenApiSchemaReference(schemaId, document) { Description = description as string, Examples = example is JsonNode exampleJson ? [exampleJson] : null, + Default = defaultAnnotation as JsonNode, }; } } diff --git a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs index f394445850fe..4864a835f4a8 100644 --- a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json.Nodes; + namespace Microsoft.AspNetCore.OpenApi; internal static class OpenApiSchemaExtensions @@ -18,4 +20,42 @@ public static IOpenApiSchema CreateOneOfNullableWrapper(this IOpenApiSchema orig ] }; } + + public static bool IsComponentizedSchema(this OpenApiSchema schema) + => schema.IsComponentizedSchema(out _); + + public static bool IsComponentizedSchema(this OpenApiSchema schema, out string schemaId) + { + if(schema.Metadata is not null + && schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var schemaIdAsObject) + && schemaIdAsObject is string schemaIdString) + { + schemaId = schemaIdString; + return true; + } + schemaId = string.Empty; + return false; + } + + public static OpenApiSchemaReference CreateReference(this OpenApiSchema schema, OpenApiDocument document) + { + if (!schema.IsComponentizedSchema(out var schemaId)) + { + throw new InvalidOperationException("Schema is not a componentized schema."); + } + + object? description = null; + object? example = null; + object? defaultAnnotation = null; + schema.Metadata?.TryGetValue(OpenApiConstants.RefDescriptionAnnotation, out description); + schema.Metadata?.TryGetValue(OpenApiConstants.RefExampleAnnotation, out example); + schema.Metadata?.TryGetValue(OpenApiConstants.RefDefaultAnnotation, out defaultAnnotation); + + return new OpenApiSchemaReference(schemaId, document) + { + Description = description as string, + Examples = example is JsonNode exampleJson ? [exampleJson] : null, + Default = defaultAnnotation as JsonNode, + }; + } } diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 877ac70010db..4ba02d1a3eb5 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -355,7 +355,11 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, schema.Metadata ??= new Dictionary(); schema.Metadata[OpenApiConstants.RefDescriptionAnnotation] = reader.GetString() ?? string.Empty; break; - + case OpenApiConstants.RefDefaultAnnotation: + reader.Read(); + schema.Metadata ??= new Dictionary(); + schema.Metadata[OpenApiConstants.RefDefaultAnnotation] = ReadJsonNode(ref reader)!; + break; default: reader.Skip(); break; diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index df4228633556..433e71573eb9 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -13,6 +13,7 @@ internal static class OpenApiConstants internal const string DescriptionId = "x-aspnetcore-id"; internal const string SchemaId = "x-schema-id"; internal const string RefId = "x-ref-id"; + internal const string RefDefaultAnnotation = "x-ref-default"; internal const string RefDescriptionAnnotation = "x-ref-description"; internal const string RefExampleAnnotation = "x-ref-example"; internal const string RefKeyword = "$ref"; diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 44bf7dbd3da5..fe63cba3767a 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -116,7 +116,7 @@ internal sealed class OpenApiSchemaService( { schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); } - var isInlinedSchema = schema[OpenApiConstants.SchemaId] is null; + var isInlinedSchema = !schema.WillBeComponentized(); if (isInlinedSchema) { if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) @@ -265,15 +265,26 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen { var schema = UnwrapOpenApiSchema(inputSchema); - if (schema.Metadata is not null && - schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var resolvedBaseSchemaId)) + var isComponentizedSchema = schema.IsComponentizedSchema(out var schemaId); + + // When we register it, this will be the resulting reference + IOpenApiSchema? resultSchemaReference = null; + if (inputSchema is OpenApiSchema && isComponentizedSchema) { - if (schema.AnyOf is { Count: > 0 }) + var targetReferenceId = baseSchemaId is not null + ? $"{baseSchemaId}{schemaId}" + : schemaId; + if (!string.IsNullOrEmpty(targetReferenceId)) { - for (var i = 0; i < schema.AnyOf.Count; i++) - { - schema.AnyOf[i] = ResolveReferenceForSchema(document, schema.AnyOf[i], rootSchemaId, resolvedBaseSchemaId?.ToString()); - } + resultSchemaReference = document.AddOpenApiSchemaByReference(targetReferenceId, schema); + } + } + + if (schema.AnyOf is { Count: > 0 }) + { + for (var i = 0; i < schema.AnyOf.Count; i++) + { + schema.AnyOf[i] = ResolveReferenceForSchema(document, schema.AnyOf[i], rootSchemaId, schemaId); } } @@ -326,39 +337,9 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen schema.Not = ResolveReferenceForSchema(document, schema.Not, rootSchemaId); } - // Handle schemas where the references have been inlined by the JsonSchemaExporter. In this case, - // the `#` ID is generated by the exporter since it has no base document to baseline against. In this - // case we we want to replace the reference ID with the schema ID that was generated by the - // `CreateSchemaReferenceId` method in the OpenApiSchemaService. - if (schema.Metadata is not null && - schema.Metadata.TryGetValue(OpenApiConstants.RefId, out var refId) && - refId is string refIdString) + if (resultSchemaReference is not null) { - if (schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var schemaId) && - schemaId is string schemaIdString) - { - return new OpenApiSchemaReference(schemaIdString, document); - } - var relativeSchemaId = $"#/components/schemas/{rootSchemaId}{refIdString.Replace("#", string.Empty)}"; - return new OpenApiSchemaReference(relativeSchemaId, document); - } - - // If we're resolving schemas for a top-level schema being referenced in the `components.schema` property - // we don't want to replace the top-level inline schema with a reference to itself. We want to replace - // inline schemas to reference schemas for all schemas referenced in the top-level schema though (such as - // `allOf`, `oneOf`, `anyOf`, `items`, `properties`, etc.) which is why `isTopLevel` is only set once. - if (schema is OpenApiSchema && schema.Metadata is not null && - !schema.Metadata.ContainsKey(OpenApiConstants.RefId) && - schema.Metadata.TryGetValue(OpenApiConstants.SchemaId, out var referenceId) && - referenceId is string referenceIdString) - { - var targetReferenceId = baseSchemaId is not null - ? $"{baseSchemaId}{referenceIdString}" - : referenceIdString; - if (!string.IsNullOrEmpty(targetReferenceId)) - { - return document.AddOpenApiSchemaByReference(targetReferenceId, schema); - } + return resultSchemaReference; } return schema; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs index 2c368e559c49..99ffe0ca3ddd 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs @@ -984,8 +984,9 @@ private void VerifyOptionalEnum(OpenApiDocument document) var property = properties["status"]; Assert.NotNull(property); - Assert.Equal(3, property.Enum.Count); - Assert.Equal("Approved", property.Default.GetValue()); + var statusReference = Assert.IsType(property); + Assert.Equal(3, statusReference.RecursiveTarget.Enum.Count); + Assert.Equal("Approved", statusReference.Default.GetValue()); } [ApiController] 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 2979e5198444..55118b879f66 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 @@ -1122,6 +1122,38 @@ await VerifyOpenApiDocument(builder, document => }); } + // Test for: https://github.com/dotnet/aspnetcore/issues/64048 + public static object[][] CircularReferencesWithArraysHandlers => + [ + [(CircularReferenceWithArrayRootOrderArrayFirst dto) => { }], + [(CircularReferenceWithArrayRootOrderArrayLast dto) => { }], + ]; + + [Theory] + [MemberData(nameof(CircularReferencesWithArraysHandlers))] + public async Task HandlesCircularReferencesWithArraysRegardlessOfPropertyOrder(Delegate requestHandler) + { + var builder = CreateBuilder(); + builder.MapPost("/", requestHandler); + + await VerifyOpenApiDocument(builder, (OpenApiDocument document) => + { + Assert.NotNull(document.Components?.Schemas); + var schema = document.Components.Schemas["CircularReferenceWithArrayModel"]; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.NotNull(schema.Properties); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("selfArray", property.Key); + var arraySchema = Assert.IsType(property.Value); + Assert.Equal(JsonSchemaType.Array, arraySchema.Type); + var itemReference = Assert.IsType(arraySchema.Items); + Assert.Equal("#/components/schemas/CircularReferenceWithArrayModel", itemReference.Reference.ReferenceV3); + }); + }); + } + // Test models for issue 61194 private class Config { @@ -1203,5 +1235,23 @@ private class ReferencedModel { public int Id { get; set; } } + + // Test models for issue 64048 + public class CircularReferenceWithArrayRootOrderArrayLast + { + public CircularReferenceWithArrayModel Item { get; set; } = null!; + public ICollection ItemArray { get; set; } = []; + } + + public class CircularReferenceWithArrayRootOrderArrayFirst + { + public ICollection ItemArray { get; set; } = []; + public CircularReferenceWithArrayModel Item { get; set; } = null!; + } + + public class CircularReferenceWithArrayModel + { + public ICollection SelfArray { get; set; } = []; + } } #nullable restore