From 439890193de7c4b013d21faba1ae7bb497136ca8 Mon Sep 17 00:00:00 2001 From: Bharath Kumar Bellam <82457244+Bellambharath@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:23:00 +0530 Subject: [PATCH 1/4] Fix OpenAPI schema ID collision handling --- .../Services/Schemas/OpenApiSchemaService.cs | 238 +++++++++++------- 1 file changed, 145 insertions(+), 93 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index eec394066f9d..3f30fdba53d6 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -24,117 +24,129 @@ namespace Microsoft.AspNetCore.OpenApi; /// an OpenAPI document. In particular, this is the API that is used to /// interact with the JSON schemas that are managed by a given OpenAPI document. /// -internal sealed class OpenApiSchemaService( - [ServiceKey] string documentName, - IOptions jsonOptions, - IOptionsMonitor optionsMonitor) +internal sealed class OpenApiSchemaService { + private readonly string _documentName; + private readonly IOptionsMonitor _optionsMonitor; private readonly ConcurrentDictionary _schemaIdCache = new(); - private readonly OpenApiJsonSchemaContext _jsonSchemaContext = new(new(jsonOptions.Value.SerializerOptions)); - private readonly JsonSerializerOptions _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions) + private readonly ConcurrentDictionary _schemaIdToType = new(StringComparer.Ordinal); + private readonly OpenApiJsonSchemaContext _jsonSchemaContext; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly JsonSchemaExporterOptions _configuration; + + public OpenApiSchemaService( + [ServiceKey] string documentName, + IOptions jsonOptions, + IOptionsMonitor optionsMonitor) { - // In order to properly handle the `RequiredAttribute` on type properties, add a modifier to support - // setting `JsonPropertyInfo.IsRequired` based on the presence of the `RequiredAttribute`. - TypeInfoResolver = jsonOptions.Value.SerializerOptions.TypeInfoResolver?.WithAddedModifier(jsonTypeInfo => - { - if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object) + _documentName = documentName; + _optionsMonitor = optionsMonitor; + _jsonSchemaContext = new(new(jsonOptions.Value.SerializerOptions)); + _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions) + { + // In order to properly handle the `RequiredAttribute` on type properties, add a modifier to support + // setting `JsonPropertyInfo.IsRequired` based on the presence of the `RequiredAttribute`. + TypeInfoResolver = jsonOptions.Value.SerializerOptions.TypeInfoResolver?.WithAddedModifier(jsonTypeInfo => { - return; - } - foreach (var propertyInfo in jsonTypeInfo.Properties) - { - var hasRequiredAttribute = propertyInfo.AttributeProvider? - .GetCustomAttributes(inherit: false) - .Any(attr => attr is RequiredAttribute); - propertyInfo.IsRequired |= hasRequiredAttribute ?? false; - } - }) - }; + if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object) + { + return; + } + foreach (var propertyInfo in jsonTypeInfo.Properties) + { + var hasRequiredAttribute = propertyInfo.AttributeProvider? + .GetCustomAttributes(inherit: false) + .Any(attr => attr is RequiredAttribute); + propertyInfo.IsRequired |= hasRequiredAttribute ?? false; + } + }) + }; - private readonly JsonSchemaExporterOptions _configuration = new() - { - TreatNullObliviousAsNonNullable = true, - TransformSchemaNode = (context, schema) => + _configuration = new JsonSchemaExporterOptions { - var type = context.TypeInfo.Type; - // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, PipeReader and FileContentResult - // that appear as properties within complex types. - if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader) || type == typeof(Mvc.FileContentResult)) + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = (context, schema) => { - schema = new JsonObject + var type = context.TypeInfo.Type; + // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, PipeReader and FileContentResult + // that appear as properties within complex types. + if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader) || type == typeof(Mvc.FileContentResult)) { - [OpenApiSchemaKeywords.TypeKeyword] = "string", - [OpenApiSchemaKeywords.FormatKeyword] = "binary", - [OpenApiConstants.SchemaId] = "IFormFile" - }; - } - else if (type == typeof(IFormFileCollection)) - { - schema = new JsonObject - { - [OpenApiSchemaKeywords.TypeKeyword] = "array", - [OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject + schema = new JsonObject { [OpenApiSchemaKeywords.TypeKeyword] = "string", [OpenApiSchemaKeywords.FormatKeyword] = "binary", [OpenApiConstants.SchemaId] = "IFormFile" - } - }; - } - else if (type.IsJsonPatchDocument()) - { - schema = CreateSchemaForJsonPatch(); - } - // STJ uses `true` in place of an empty object to represent a schema that matches - // anything (like the `object` type) or types with user-defined converters. We override - // this default behavior here to match the format expected in OpenAPI v3. - if (schema.GetValueKind() == JsonValueKind.True) - { - schema = new JsonObject(); - } - var createSchemaReferenceId = optionsMonitor.Get(documentName).CreateSchemaReferenceId; - schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId); - schema.ApplySchemaReferenceId(context, createSchemaReferenceId); - schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId); - if (context.PropertyInfo is { } jsonPropertyInfo) - { - schema.ApplyNullabilityContextInfo(jsonPropertyInfo); - } - if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) - { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; - } - if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) - { - var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); - if (propertyAttributes.OfType() is { } validationAttributes) + }; + } + else if (type == typeof(IFormFileCollection)) { - schema.ApplyValidationAttributes(validationAttributes); + schema = new JsonObject + { + [OpenApiSchemaKeywords.TypeKeyword] = "array", + [OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.FormatKeyword] = "binary", + [OpenApiConstants.SchemaId] = "IFormFile" + } + }; } - if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) + else if (type.IsJsonPatchDocument()) { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + schema = CreateSchemaForJsonPatch(); } - var isInlinedSchema = !schema.WillBeComponentized(); - if (isInlinedSchema) + // STJ uses `true` in place of an empty object to represent a schema that matches + // anything (like the `object` type) or types with user-defined converters. We override + // this default behavior here to match the format expected in OpenAPI v3. + if (schema.GetValueKind() == JsonValueKind.True) { - if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) - { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; - } + schema = new JsonObject(); } - else + var createSchemaReferenceId = GetSchemaReferenceId; + schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId); + schema.ApplySchemaReferenceId(context, createSchemaReferenceId); + schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId); + if (context.PropertyInfo is { } jsonPropertyInfo) + { + schema.ApplyNullabilityContextInfo(jsonPropertyInfo); + } + if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; + } + if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { - if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) + var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); + if (propertyAttributes.OfType() is { } validationAttributes) { - schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; + schema.ApplyValidationAttributes(validationAttributes); + } + if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) + { + schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + } + var isInlinedSchema = !schema.WillBeComponentized(); + if (isInlinedSchema) + { + if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; + } + } + else + { + if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) + { + schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; + } } } + schema.PruneNullTypeForComponentizedTypes(); + return schema; } - schema.PruneNullTypeForComponentizedTypes(); - return schema; - } - }; + }; + } private static JsonObject CreateSchemaForJsonPatch() { @@ -248,13 +260,53 @@ internal async Task GetOrCreateSchemaAsync(OpenApiDocument docum // Cache the root schema IDs since we expect to be called // on the same type multiple times within an API - var baseSchemaId = _schemaIdCache.GetOrAdd(type, t => + var baseSchemaId = GetSchemaReferenceId(_jsonSerializerOptions.GetTypeInfo(type)); + + return ResolveReferenceForSchema(document, schema, baseSchemaId); + } + + private string? GetSchemaReferenceId(JsonTypeInfo jsonTypeInfo) + { + return _schemaIdCache.GetOrAdd(jsonTypeInfo.Type, _ => { - var jsonTypeInfo = _jsonSerializerOptions.GetTypeInfo(t); - return optionsMonitor.Get(documentName).CreateSchemaReferenceId(jsonTypeInfo); + var schemaId = _optionsMonitor.Get(_documentName).CreateSchemaReferenceId(jsonTypeInfo); + if (string.IsNullOrEmpty(schemaId)) + { + return schemaId; + } + + return EnsureUniqueSchemaReferenceId(schemaId, jsonTypeInfo.Type, _schemaIdToType); }); + } - return ResolveReferenceForSchema(document, schema, baseSchemaId); + private static string EnsureUniqueSchemaReferenceId(string schemaId, Type type, ConcurrentDictionary schemaIdToType) + { + if (schemaIdToType.TryAdd(schemaId, type)) + { + return schemaId; + } + + if (schemaIdToType.TryGetValue(schemaId, out var existing) && existing == type) + { + return schemaId; + } + + var suffix = 2; + while (true) + { + var candidate = $"{schemaId}{suffix}"; + if (schemaIdToType.TryAdd(candidate, type)) + { + return candidate; + } + + if (schemaIdToType.TryGetValue(candidate, out existing) && existing == type) + { + return candidate; + } + + suffix++; + } } internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument document, IOpenApiSchema inputSchema, string? rootSchemaId, string? baseSchemaId = null) @@ -377,7 +429,7 @@ internal async Task ApplySchemaTransformersAsync(OpenApiDocument? document, IOpe var jsonTypeInfo = _jsonSerializerOptions.GetTypeInfo(type); var context = new OpenApiSchemaTransformerContext { - DocumentName = documentName, + DocumentName = _documentName, JsonTypeInfo = jsonTypeInfo, JsonPropertyInfo = null, ParameterDescription = parameterDescription, From 20e47b1297d4b4e894c5c89c88eebcbeefc8b3e4 Mon Sep 17 00:00:00 2001 From: Bharath Kumar Bellam <82457244+Bellambharath@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:25:56 +0530 Subject: [PATCH 2/4] Update OpenAPI schema ID tests --- .../Services/CreateSchemaReferenceIdTests.cs | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/CreateSchemaReferenceIdTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/CreateSchemaReferenceIdTests.cs index 62a184e7a951..532481381958 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/CreateSchemaReferenceIdTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/CreateSchemaReferenceIdTests.cs @@ -199,7 +199,7 @@ await VerifyOpenApiDocument(builder, options, document => }); } - [ConditionalFact(Skip = "https://github.com/dotnet/aspnetcore/issues/58619")] + [Fact] public async Task HandlesDuplicateSchemaReferenceIdsGeneratedByOverload() { var builder = CreateBuilder(); @@ -249,7 +249,7 @@ await VerifyOpenApiDocument(builder, options, document => property => { Assert.Equal("dueDate", property.Key); - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + Assert.Equal(JsonSchemaType.String, property.Value.Type); Assert.Equal("date-time", property.Value.Format); }, property => @@ -270,7 +270,7 @@ await VerifyOpenApiDocument(builder, options, document => property => { Assert.Equal("createdAt", property.Key); - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + Assert.Equal(JsonSchemaType.String, property.Value.Type); Assert.Equal("date-time", property.Value.Format); }); @@ -295,10 +295,52 @@ await VerifyOpenApiDocument(builder, options, document => property => { Assert.Equal("createdAt", property.Key); - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + Assert.Equal(JsonSchemaType.String, property.Value.Type); Assert.Equal("date-time", property.Value.Format); }); }); } + [Fact] + public async Task DedupesSchemaReferenceIds_WhenTypesShareName() + { + var builder = CreateBuilder(); + + builder.MapPost("/a", (NamespaceA.Widget widget) => { }); + builder.MapPost("/b", (NamespaceB.Widget widget) => { }); + + await VerifyOpenApiDocument(builder, document => + { + var opA = document.Paths["/a"].Operations[HttpMethod.Post]; + var schemaARef = Assert.IsType(opA.RequestBody.Content["application/json"].Schema); + + var opB = document.Paths["/b"].Operations[HttpMethod.Post]; + var schemaBRef = Assert.IsType(opB.RequestBody.Content["application/json"].Schema); + + Assert.NotEqual(schemaARef.Reference.Id, schemaBRef.Reference.Id); + + var schemaA = document.Components.Schemas[schemaARef.Reference.Id]; + var schemaB = document.Components.Schemas[schemaBRef.Reference.Id]; + + Assert.Contains("aValue", schemaA.Properties.Keys); + Assert.Contains("bValue", schemaB.Properties.Keys); + }); + } + + private static class NamespaceA + { + public class Widget + { + public string AValue { get; set; } = string.Empty; + } + } + + private static class NamespaceB + { + public class Widget + { + public int BValue { get; set; } + } + } + } From 743573b8d5c79cb6f90f2fdf6e7e38c46b4000ba Mon Sep 17 00:00:00 2001 From: Bharath Kumar Bellam <82457244+Bellambharath@users.noreply.github.com> Date: Sun, 31 May 2026 21:35:35 +0530 Subject: [PATCH 3/4] fix: resolve merge conflicts with upstream main - add FileStreamResult/FileContentHttpResult/FileStreamHttpResult support and inlineEnumParam logic --- .../Services/Schemas/OpenApiSchemaService.cs | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 3f30fdba53d6..eb3529fab60c 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -12,8 +12,10 @@ using System.Text.Json.Schema; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -68,9 +70,12 @@ public OpenApiSchemaService( TransformSchemaNode = (context, schema) => { var type = context.TypeInfo.Type; - // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, PipeReader and FileContentResult + // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, PipeReader, + // FileContentResult, FileStreamResult, FileContentHttpResult and FileStreamHttpResult // that appear as properties within complex types. - if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader) || type == typeof(Mvc.FileContentResult)) + if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader) + || type == typeof(Mvc.FileContentResult) || type == typeof(Mvc.FileStreamResult) + || type == typeof(FileContentHttpResult) || type == typeof(FileStreamHttpResult)) { schema = new JsonObject { @@ -256,8 +261,48 @@ internal async Task GetOrCreateUnresolvedSchemaAsync(OpenApiDocum internal async Task GetOrCreateSchemaAsync(OpenApiDocument document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) { + // For non-body enum parameters, check if a naming policy transforms the enum values. + // If so, skip componentization and return an inline schema with the original C# member + // names (which Enum.TryParse accepts). The component schema keeps the naming-policy + // values for body serialization. + var inlineEnumParam = false; + if (parameterDescription is { Source: { } source, Type: { } paramType } + && IsNonBodyBindingSource(source) + && (Nullable.GetUnderlyingType(paramType) ?? paramType) is { IsEnum: true } enumType) + { + var rawNode = CreateSchema(type); + if (rawNode[OpenApiSchemaKeywords.EnumKeyword] is JsonArray rawEnum && rawEnum.Count > 0) + { + var memberNames = Enum.GetNames(enumType); + for (var i = 0; i < memberNames.Length && i < rawEnum.Count; i++) + { + if (rawEnum[i]?.GetValue() != memberNames[i]) + { + inlineEnumParam = true; + break; + } + } + } + } + var schema = await GetOrCreateUnresolvedSchemaAsync(document, type, scopedServiceProvider, schemaTransformers, parameterDescription, cancellationToken); + if (inlineEnumParam) + { + // The schema was originally tagged for componentization (x-schema-id was set), + // so ApplyDefaultValue stored the default in the x-ref-default metadata annotation + // instead of the "default" keyword. Since we're now inlining this schema, promote + // the annotation to the schema's Default property. + if (schema.Metadata?.TryGetValue(OpenApiConstants.RefDefaultAnnotation, out var refDefault) == true + && refDefault is JsonNode defaultNode) + { + schema.Default = defaultNode; + schema.Metadata.Remove(OpenApiConstants.RefDefaultAnnotation); + } + + return schema; + } + // Cache the root schema IDs since we expect to be called // on the same type multiple times within an API var baseSchemaId = GetSchemaReferenceId(_jsonSerializerOptions.GetTypeInfo(type)); @@ -265,6 +310,12 @@ internal async Task GetOrCreateSchemaAsync(OpenApiDocument docum return ResolveReferenceForSchema(document, schema, baseSchemaId); } + private static bool IsNonBodyBindingSource(BindingSource bindingSource) => bindingSource == BindingSource.Header + || bindingSource == BindingSource.Query + || bindingSource == BindingSource.Path + || bindingSource == BindingSource.Form + || bindingSource == BindingSource.FormFile; + private string? GetSchemaReferenceId(JsonTypeInfo jsonTypeInfo) { return _schemaIdCache.GetOrAdd(jsonTypeInfo.Type, _ => From 757a77c480523038cd87d2118239114f3f254c4a Mon Sep 17 00:00:00 2001 From: Bharath Kumar Bellam <82457244+Bellambharath@users.noreply.github.com> Date: Sun, 31 May 2026 22:02:27 +0530 Subject: [PATCH 4/4] Resolve merge conflict: rebase collision-fix onto updated upstream main Upstream main refactored OpenApiSchemaService to use a primary constructor and added ResolveReferences/ResolveReferencesRecursive/ResolveReference methods for $ref inlining. This commit merges both changes: - Keeps upstream's primary constructor pattern - Keeps upstream's $ref resolution methods - Preserves our schema ID collision-detection logic: _schemaIdToType, GetSchemaReferenceId(), EnsureUniqueSchemaReferenceId() --- .../Services/Schemas/OpenApiSchemaService.cs | 229 +++++++++--------- 1 file changed, 111 insertions(+), 118 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index eb3529fab60c..0b454d9ca78e 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -26,132 +26,123 @@ namespace Microsoft.AspNetCore.OpenApi; /// an OpenAPI document. In particular, this is the API that is used to /// interact with the JSON schemas that are managed by a given OpenAPI document. /// -internal sealed class OpenApiSchemaService +internal sealed class OpenApiSchemaService( + [ServiceKey] string documentName, + IOptions jsonOptions, + IOptionsMonitor optionsMonitor) { - private readonly string _documentName; - private readonly IOptionsMonitor _optionsMonitor; private readonly ConcurrentDictionary _schemaIdCache = new(); + // Tracks schema ID -> Type mapping so we can detect and resolve duplicate schema IDs + // across types that happen to share the same name (e.g. same class name in different namespaces). private readonly ConcurrentDictionary _schemaIdToType = new(StringComparer.Ordinal); - private readonly OpenApiJsonSchemaContext _jsonSchemaContext; - private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly JsonSchemaExporterOptions _configuration; - - public OpenApiSchemaService( - [ServiceKey] string documentName, - IOptions jsonOptions, - IOptionsMonitor optionsMonitor) + private readonly OpenApiJsonSchemaContext _jsonSchemaContext = new(new(jsonOptions.Value.SerializerOptions)); + private readonly JsonSerializerOptions _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions) { - _documentName = documentName; - _optionsMonitor = optionsMonitor; - _jsonSchemaContext = new(new(jsonOptions.Value.SerializerOptions)); - _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions) + // In order to properly handle the `RequiredAttribute` on type properties, add a modifier to support + // setting `JsonPropertyInfo.IsRequired` based on the presence of the `RequiredAttribute`. + TypeInfoResolver = jsonOptions.Value.SerializerOptions.TypeInfoResolver?.WithAddedModifier(jsonTypeInfo => { - // In order to properly handle the `RequiredAttribute` on type properties, add a modifier to support - // setting `JsonPropertyInfo.IsRequired` based on the presence of the `RequiredAttribute`. - TypeInfoResolver = jsonOptions.Value.SerializerOptions.TypeInfoResolver?.WithAddedModifier(jsonTypeInfo => + if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object) { - if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object) - { - return; - } - foreach (var propertyInfo in jsonTypeInfo.Properties) - { - var hasRequiredAttribute = propertyInfo.AttributeProvider? - .GetCustomAttributes(inherit: false) - .Any(attr => attr is RequiredAttribute); - propertyInfo.IsRequired |= hasRequiredAttribute ?? false; - } - }) - }; + return; + } + foreach (var propertyInfo in jsonTypeInfo.Properties) + { + var hasRequiredAttribute = propertyInfo.AttributeProvider? + .GetCustomAttributes(inherit: false) + .Any(attr => attr is RequiredAttribute); + propertyInfo.IsRequired |= hasRequiredAttribute ?? false; + } + }) + }; - _configuration = new JsonSchemaExporterOptions - { - TreatNullObliviousAsNonNullable = true, - TransformSchemaNode = (context, schema) => + private readonly JsonSchemaExporterOptions _configuration = new() + { + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = (context, schema) => + { + var type = context.TypeInfo.Type; + // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, PipeReader, + // FileContentResult, FileStreamResult, FileContentHttpResult and FileStreamHttpResult + // that appear as properties within complex types. + if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader) + || type == typeof(Mvc.FileContentResult) || type == typeof(Mvc.FileStreamResult) + || type == typeof(FileContentHttpResult) || type == typeof(FileStreamHttpResult)) + { + schema = new JsonObject + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.FormatKeyword] = "binary", + [OpenApiConstants.SchemaId] = "IFormFile" + }; + } + else if (type == typeof(IFormFileCollection)) { - var type = context.TypeInfo.Type; - // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, PipeReader, - // FileContentResult, FileStreamResult, FileContentHttpResult and FileStreamHttpResult - // that appear as properties within complex types. - if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader) - || type == typeof(Mvc.FileContentResult) || type == typeof(Mvc.FileStreamResult) - || type == typeof(FileContentHttpResult) || type == typeof(FileStreamHttpResult)) + schema = new JsonObject { - schema = new JsonObject + [OpenApiSchemaKeywords.TypeKeyword] = "array", + [OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject { [OpenApiSchemaKeywords.TypeKeyword] = "string", [OpenApiSchemaKeywords.FormatKeyword] = "binary", [OpenApiConstants.SchemaId] = "IFormFile" - }; - } - else if (type == typeof(IFormFileCollection)) - { - schema = new JsonObject - { - [OpenApiSchemaKeywords.TypeKeyword] = "array", - [OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject - { - [OpenApiSchemaKeywords.TypeKeyword] = "string", - [OpenApiSchemaKeywords.FormatKeyword] = "binary", - [OpenApiConstants.SchemaId] = "IFormFile" - } - }; - } - else if (type.IsJsonPatchDocument()) - { - schema = CreateSchemaForJsonPatch(); - } - // STJ uses `true` in place of an empty object to represent a schema that matches - // anything (like the `object` type) or types with user-defined converters. We override - // this default behavior here to match the format expected in OpenAPI v3. - if (schema.GetValueKind() == JsonValueKind.True) - { - schema = new JsonObject(); - } - var createSchemaReferenceId = GetSchemaReferenceId; - schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId); - schema.ApplySchemaReferenceId(context, createSchemaReferenceId); - schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId); - if (context.PropertyInfo is { } jsonPropertyInfo) + } + }; + } + else if (type.IsJsonPatchDocument()) + { + schema = CreateSchemaForJsonPatch(); + } + // STJ uses `true` in place of an empty object to represent a schema that matches + // anything (like the `object` type) or types with user-defined converters. We override + // this default behavior here to match the format expected in OpenAPI v3. + if (schema.GetValueKind() == JsonValueKind.True) + { + schema = new JsonObject(); + } + var createSchemaReferenceId = optionsMonitor.Get(documentName).CreateSchemaReferenceId; + schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId); + schema.ApplySchemaReferenceId(context, createSchemaReferenceId); + schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId); + if (context.PropertyInfo is { } jsonPropertyInfo) + { + schema.ApplyNullabilityContextInfo(jsonPropertyInfo); + } + if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; + } + if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) + { + var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); + if (propertyAttributes.OfType() is { } validationAttributes) { - schema.ApplyNullabilityContextInfo(jsonPropertyInfo); + schema.ApplyValidationAttributes(validationAttributes); } - if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) + if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; + schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); } - if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) + var isInlinedSchema = !schema.WillBeComponentized(); + if (isInlinedSchema) { - var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); - if (propertyAttributes.OfType() is { } validationAttributes) - { - schema.ApplyValidationAttributes(validationAttributes); - } - if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) - { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); - } - var isInlinedSchema = !schema.WillBeComponentized(); - if (isInlinedSchema) + if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) { - if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) - { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; - } + schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; } - else + } + else + { + if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) { - if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) - { - schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; - } + schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; } } - schema.PruneNullTypeForComponentizedTypes(); - return schema; } - }; - } + schema.PruneNullTypeForComponentizedTypes(); + return schema; + } + }; private static JsonObject CreateSchemaForJsonPatch() { @@ -305,7 +296,18 @@ internal async Task GetOrCreateSchemaAsync(OpenApiDocument docum // Cache the root schema IDs since we expect to be called // on the same type multiple times within an API - var baseSchemaId = GetSchemaReferenceId(_jsonSerializerOptions.GetTypeInfo(type)); + var baseSchemaId = _schemaIdCache.GetOrAdd(type, t => + { + var jsonTypeInfo = _jsonSerializerOptions.GetTypeInfo(t); + var schemaId = optionsMonitor.Get(documentName).CreateSchemaReferenceId(jsonTypeInfo); + if (string.IsNullOrEmpty(schemaId)) + { + return schemaId; + } + // Make sure two different types with the same name don't end up sharing + // the same schema ID in the components section — append a numeric suffix if needed. + return EnsureUniqueSchemaReferenceId(schemaId, t, _schemaIdToType); + }); return ResolveReferenceForSchema(document, schema, baseSchemaId); } @@ -316,20 +318,11 @@ private static bool IsNonBodyBindingSource(BindingSource bindingSource) => bindi || bindingSource == BindingSource.Form || bindingSource == BindingSource.FormFile; - private string? GetSchemaReferenceId(JsonTypeInfo jsonTypeInfo) - { - return _schemaIdCache.GetOrAdd(jsonTypeInfo.Type, _ => - { - var schemaId = _optionsMonitor.Get(_documentName).CreateSchemaReferenceId(jsonTypeInfo); - if (string.IsNullOrEmpty(schemaId)) - { - return schemaId; - } - - return EnsureUniqueSchemaReferenceId(schemaId, jsonTypeInfo.Type, _schemaIdToType); - }); - } - + /// + /// Appends a numeric suffix (2, 3, ...) to until we find one + /// that hasn't been claimed by a different type yet. This prevents two different types that + /// happen to share the same short name from clobbering each other in the components section. + /// private static string EnsureUniqueSchemaReferenceId(string schemaId, Type type, ConcurrentDictionary schemaIdToType) { if (schemaIdToType.TryAdd(schemaId, type)) @@ -480,7 +473,7 @@ internal async Task ApplySchemaTransformersAsync(OpenApiDocument? document, IOpe var jsonTypeInfo = _jsonSerializerOptions.GetTypeInfo(type); var context = new OpenApiSchemaTransformerContext { - DocumentName = _documentName, + DocumentName = documentName, JsonTypeInfo = jsonTypeInfo, JsonPropertyInfo = null, ParameterDescription = parameterDescription,