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, 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; } + } + } + }