diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index eec394066f9d..0b454d9ca78e 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; @@ -30,6 +32,9 @@ internal sealed class OpenApiSchemaService( 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 = new(new(jsonOptions.Value.SerializerOptions)); private readonly JsonSerializerOptions _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions) { @@ -57,9 +62,12 @@ internal sealed class 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 { @@ -244,19 +252,107 @@ 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 = _schemaIdCache.GetOrAdd(type, t => { var jsonTypeInfo = _jsonSerializerOptions.GetTypeInfo(t); - return optionsMonitor.Get(documentName).CreateSchemaReferenceId(jsonTypeInfo); + 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); } + private static bool IsNonBodyBindingSource(BindingSource bindingSource) => bindingSource == BindingSource.Header + || bindingSource == BindingSource.Query + || bindingSource == BindingSource.Path + || bindingSource == BindingSource.Form + || bindingSource == BindingSource.FormFile; + + /// + /// 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)) + { + 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) { var schema = UnwrapOpenApiSchema(inputSchema); 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; } + } + } + }