diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index c9d90f5d9e5a..830b0375d396 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -280,6 +280,11 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, break; case OpenApiSchemaKeywords.AdditionalPropertiesKeyword: reader.Read(); + if (reader.TokenType == JsonTokenType.False) + { + schema.AdditionalPropertiesAllowed = false; + break; + } var additionalPropsConverter = (JsonConverter)options.GetTypeInfo(typeof(OpenApiJsonSchema)).Converter; schema.AdditionalProperties = additionalPropsConverter.Read(ref reader, typeof(OpenApiJsonSchema), options)?.Schema; break; diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 2745d64770a7..c594ebb7ac08 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Collections.Frozen; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -46,7 +47,7 @@ internal sealed class OpenApiDocumentService( /// are unique within the lifetime of an application and serve as helpful associators between /// operations, API descriptions, and their respective transformer contexts. /// - private readonly Dictionary _operationTransformerContextCache = new(); + private readonly ConcurrentDictionary _operationTransformerContextCache = new(); private static readonly ApiResponseType _defaultApiResponseType = new() { StatusCode = StatusCodes.Status200OK }; private static readonly FrozenSet _disallowedHeaderParameters = new[] { HeaderNames.Accept, HeaderNames.Authorization, HeaderNames.ContentType }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); @@ -402,6 +403,12 @@ private async Task GetResponseAsync( continue; } + // MVC's ModelMetadata layer will set ApiParameterDescription.Type to string when the parameter + // is a parsable or convertible type. In this case, we want to use the actual model type + // to generate the schema instead of the string type. + var targetType = parameter.Type == typeof(string) && parameter.ModelMetadata.ModelType != parameter.Type + ? parameter.ModelMetadata.ModelType + : parameter.Type; var openApiParameter = new OpenApiParameter { Name = parameter.Name, @@ -413,7 +420,7 @@ private async Task GetResponseAsync( _ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}") }, Required = IsRequired(parameter), - Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken), + Schema = await _componentService.GetOrCreateSchemaAsync(targetType, scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken), Description = GetParameterDescriptionFromAttribute(parameter) }; diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs index 88f1dd4633af..304aacdfa98d 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.IO.Pipelines; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; @@ -14,7 +15,7 @@ namespace Microsoft.AspNetCore.OpenApi; /// internal sealed class OpenApiSchemaStore { - private readonly Dictionary _schemas = new() + private readonly ConcurrentDictionary _schemas = new() { // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core. [new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject @@ -48,8 +49,8 @@ internal sealed class OpenApiSchemaStore }, }; - public readonly Dictionary SchemasByReference = new(OpenApiSchemaComparer.Instance); - private readonly Dictionary _referenceIdCounter = new(); + public readonly ConcurrentDictionary SchemasByReference = new(OpenApiSchemaComparer.Instance); + private readonly ConcurrentDictionary _referenceIdCounter = new(); /// /// Resolves the JSON schema for the given type and parameter description. @@ -59,13 +60,7 @@ internal sealed class OpenApiSchemaStore /// A representing the JSON schema associated with the key. public JsonNode GetOrAdd(OpenApiSchemaKey key, Func valueFactory) { - if (_schemas.TryGetValue(key, out var schema)) - { - return schema; - } - var targetSchema = valueFactory(key); - _schemas.Add(key, targetSchema); - return targetSchema; + return _schemas.GetOrAdd(key, valueFactory); } /// @@ -159,6 +154,14 @@ private void AddOrUpdateAnyOfSubSchemaByReference(OpenApiSchema schema) private void AddOrUpdateSchemaByReference(OpenApiSchema schema, string? baseTypeSchemaId = null, bool captureSchemaByRef = false) { var targetReferenceId = baseTypeSchemaId is not null ? $"{baseTypeSchemaId}{GetSchemaReferenceId(schema)}" : GetSchemaReferenceId(schema); + // Schemas that already have a reference provided by JsonSchemaExporter are skipped here + // and handled by the OpenApiSchemaReferenceTransformer instead. This case typically kicks + // in for self-referencing schemas where JsonSchemaExporter inlines references to avoid + // infinite recursion. + if (schema.Reference is not null) + { + return; + } if (SchemasByReference.TryGetValue(schema, out var referenceId) || captureSchemaByRef) { // If we've already used this reference ID else where in the document, increment a counter value to the reference diff --git a/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs b/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs index 07c76fe22974..ee7e166daab7 100644 --- a/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs +++ b/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; @@ -85,7 +86,7 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC /// The inline schema to replace with a reference. /// A cache of schemas and their associated reference IDs. /// When , will skip resolving references for the top-most schema provided. - internal static OpenApiSchema? ResolveReferenceForSchema(OpenApiSchema? schema, Dictionary schemasByReference, bool isTopLevel = false) + internal static OpenApiSchema? ResolveReferenceForSchema(OpenApiSchema? schema, ConcurrentDictionary schemasByReference, bool isTopLevel = false) { if (schema is null) { @@ -101,6 +102,16 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = referenceId } }; } + // Handle schemas where the references have been inline 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 (!isTopLevel && schema.Reference is { Type: ReferenceType.Schema, Id: "#" } + && schema.Annotations.TryGetValue(OpenApiConstants.SchemaId, out var schemaId)) + { + return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = schemaId?.ToString() } }; + } + if (schema.AllOf is not null) { for (var i = 0; i < schema.AllOf.Count; i++) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentConcurrentRequestTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentConcurrentRequestTests.cs new file mode 100644 index 000000000000..3f2ce1177aa3 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentConcurrentRequestTests.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; + +namespace Microsoft.AspNetCore.OpenApi.Tests.Integration; + +public class OpenApiDocumentConcurrentRequestTests(SampleAppFixture fixture) : IClassFixture +{ + [Fact] + public async Task MapOpenApi_HandlesConcurrentRequests() + { + // Arrange + var client = fixture.CreateClient(); + + // Act + await Parallel.ForAsync(0, 150, async (_, ctx) => + { + var response = await client.GetAsync("/openapi/v1.json", ctx); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs index af9b7e5663d7..4842e189ade4 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs @@ -537,4 +537,59 @@ await VerifyOpenApiDocument(builder, document => Assert.Null(operation.RequestBody.Content["application/json"].Schema.Type); }); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SupportsParameterWithEnumType(bool useAction) + { + // Arrange + if (!useAction) + { + var builder = CreateBuilder(); + builder.MapGet("/api/with-enum", (ItemStatus status) => status); + } + else + { + var action = CreateActionDescriptor(nameof(GetItemStatus)); + await VerifyOpenApiDocument(action, AssertOpenApiDocument); + } + + static void AssertOpenApiDocument(OpenApiDocument document) + { + var operation = document.Paths["/api/with-enum"].Operations[OperationType.Get]; + var parameter = Assert.Single(operation.Parameters); + var response = Assert.Single(operation.Responses).Value.Content["application/json"].Schema; + Assert.NotNull(parameter.Schema.Reference); + Assert.Equal(parameter.Schema.Reference.Id, response.Reference.Id); + var schema = parameter.Schema.GetEffective(document); + Assert.Collection(schema.Enum, + value => + { + var openApiString = Assert.IsType(value); + Assert.Equal("Pending", openApiString.Value); + }, + value => + { + var openApiString = Assert.IsType(value); + Assert.Equal("Approved", openApiString.Value); + }, + value => + { + var openApiString = Assert.IsType(value); + Assert.Equal("Rejected", openApiString.Value); + }); + } + } + + [Route("/api/with-enum")] + private ItemStatus GetItemStatus([FromQuery] ItemStatus status) => status; + + [JsonConverter(typeof(JsonStringEnumConverter))] + internal enum ItemStatus + { + Pending = 0, + Approved = 1, + Rejected = 2, + } } 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 e7d0ea13af19..3e95257e874b 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 @@ -4,6 +4,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO.Pipelines; +using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; @@ -594,4 +595,107 @@ await VerifyOpenApiDocument(builder, document => }); }); } + + [Fact] + public async Task SupportsClassWithJsonUnmappedMemberHandlingDisallowed() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (ExampleWithDisallowedUnmappedMembers type) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema.GetEffective(document); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("number", property.Key); + Assert.Equal("integer", property.Value.Type); + }); + Assert.False(schema.AdditionalPropertiesAllowed); + }); + } + + [Fact] + public async Task SupportsClassWithJsonUnmappedMemberHandlingSkipped() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (ExampleWithSkippedUnmappedMembers type) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema.GetEffective(document); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("number", property.Key); + Assert.Equal("integer", property.Value.Type); + }); + Assert.True(schema.AdditionalPropertiesAllowed); + }); + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + private class ExampleWithDisallowedUnmappedMembers + { + public int Number { get; init; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Skip)] + private class ExampleWithSkippedUnmappedMembers + { + public int Number { get; init; } + } + + [Fact] + public async Task SupportsTypesWithSelfReferencedProperties() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (Parent parent) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema.GetEffective(document); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("selfReferenceList", property.Key); + Assert.Equal("array", property.Value.Type); + Assert.Equal("Parent", property.Value.Items.Reference.Id); + }, + property => + { + Assert.Equal("selfReferenceDictionary", property.Key); + Assert.Equal("object", property.Value.Type); + Assert.Equal("Parent", property.Value.AdditionalProperties.Reference.Id); + }); + }); + } + + public class Parent + { + public IEnumerable SelfReferenceList { get; set; } = [ ]; + public IDictionary SelfReferenceDictionary { get; set; } = new Dictionary(); + } + } 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 eecb520c1bb0..4d16ff51d4e7 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 @@ -450,4 +450,31 @@ await VerifyOpenApiDocument(builder, document => } }); } + + [Fact] + public async Task SelfReferenceMapperOnlyOperatesOnSchemaReferenceTypes() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer((schema, context, cancellationToken) => + { + if (context.JsonTypeInfo.Type == typeof(Todo)) + { + schema.Reference = new OpenApiReference { Id = "#", Type = ReferenceType.Link }; + } + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + var operation = document.Paths["/todo"].Operations[OperationType.Get]; + var response = operation.Responses["200"].Content["application/json"]; + var responseSchema = response.Schema; + Assert.Equal("#", responseSchema.Reference.Id); + Assert.Equal(ReferenceType.Link, responseSchema.Reference.Type); + }); + } }