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