Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,6 +32,9 @@ internal sealed class OpenApiSchemaService(
IOptionsMonitor<OpenApiOptions> optionsMonitor)
{
private readonly ConcurrentDictionary<Type, string?> _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<string, Type> _schemaIdToType = new(StringComparer.Ordinal);
private readonly OpenApiJsonSchemaContext _jsonSchemaContext = new(new(jsonOptions.Value.SerializerOptions));
private readonly JsonSerializerOptions _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions)
{
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -244,19 +252,107 @@ internal async Task<OpenApiSchema> GetOrCreateUnresolvedSchemaAsync(OpenApiDocum

internal async Task<IOpenApiSchema> 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<string>() != 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;

/// <summary>
/// Appends a numeric suffix (2, 3, ...) to <paramref name="schemaId"/> 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.
/// </summary>
private static string EnsureUniqueSchemaReferenceId(string schemaId, Type type, ConcurrentDictionary<string, Type> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 =>
Expand All @@ -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);
});

Expand All @@ -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<OpenApiSchemaReference>(opA.RequestBody.Content["application/json"].Schema);

var opB = document.Paths["/b"].Operations[HttpMethod.Post];
var schemaBRef = Assert.IsType<OpenApiSchemaReference>(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; }
}
}

}