diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs
index 945326b1d5b1..969cc614a421 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,36 @@ 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();
+ if (!string.IsNullOrEmpty(schemaId))
+ {
+ 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 +493,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 +507,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..c8799e0c06a9 100644
--- a/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs
+++ b/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs
@@ -14,28 +14,33 @@ internal static class OpenApiDocumentExtensions
/// The to register the schema onto.
/// The ID that serves as the key for the schema in the schema store.
/// The to register into the document.
- /// An with a reference to the stored schema.
- public static IOpenApiSchema AddOpenApiSchemaByReference(this OpenApiDocument document, string schemaId, IOpenApiSchema schema)
+ /// An with a reference to the stored schema.
+ /// Whether the schema was added or already existed
+ public static bool AddOpenApiSchemaByReference(this OpenApiDocument document, string schemaId, IOpenApiSchema schema, out OpenApiSchemaReference schemaReference)
{
- 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.
+ var schemaAdded = 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)
+ schemaReference = new OpenApiSchemaReference(schemaId, document)
{
Description = description as string,
Examples = example is JsonNode exampleJson ? [exampleJson] : null,
+ Default = defaultAnnotation as JsonNode,
};
+
+ return schemaAdded;
}
}
diff --git a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs
index f394445850fe..705ffc8933ec 100644
--- a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs
+++ b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs
@@ -18,4 +18,21 @@ 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
+ && !string.IsNullOrEmpty(schemaIdString))
+ {
+ schemaId = schemaIdString;
+ return true;
+ }
+ schemaId = string.Empty;
+ return false;
+ }
}
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/OpenApiSchemaKey.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaKey.cs
deleted file mode 100644
index 7286ce119d0a..000000000000
--- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaKey.cs
+++ /dev/null
@@ -1,12 +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 System.Reflection;
-
-namespace Microsoft.AspNetCore.OpenApi;
-
-///
-/// Represents a unique identifier that is used to store and retrieve
-/// JSON schemas associated with a given property.
-///
-internal record struct OpenApiSchemaKey(Type Type, ParameterInfo? ParameterInfo);
diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
index 44bf7dbd3da5..eec394066f9d 100644
--- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
+++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
@@ -14,7 +14,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -116,7 +115,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)
@@ -229,10 +228,7 @@ static JsonArray JsonArray(ReadOnlySpan values)
internal async Task GetOrCreateUnresolvedSchemaAsync(OpenApiDocument? document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default)
{
- var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription
- && parameterDescription.ModelMetadata.PropertyName is null
- ? new OpenApiSchemaKey(type, parameterInfoDescription.ParameterInfo) : new OpenApiSchemaKey(type, null);
- var schemaAsJsonObject = CreateSchema(key);
+ var schemaAsJsonObject = CreateSchema(type);
if (parameterDescription is not null)
{
schemaAsJsonObject.ApplyParameterInfo(parameterDescription, _jsonSerializerOptions.GetTypeInfo(type));
@@ -265,18 +261,33 @@ 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
+ OpenApiSchemaReference? 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++)
+ if (!document.AddOpenApiSchemaByReference(targetReferenceId, schema, out resultSchemaReference))
{
- schema.AnyOf[i] = ResolveReferenceForSchema(document, schema.AnyOf[i], rootSchemaId, resolvedBaseSchemaId?.ToString());
+ // We already added this schema, so it has already been resolved.
+ return resultSchemaReference;
}
}
}
+ 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);
+ }
+ }
+
if (schema.Properties is not null)
{
foreach (var property in schema.Properties)
@@ -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;
@@ -466,9 +447,9 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema,
}
}
- private JsonNode CreateSchema(OpenApiSchemaKey key)
+ private JsonNode CreateSchema(Type type)
{
- var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration);
+ var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, type, _configuration);
return ResolveReferences(schema, 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