diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index e84200ba1e60..629fbbb86f29 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -1,2 +1,9 @@ #nullable enable static Microsoft.AspNetCore.Builder.OpenApiEndpointConventionBuilderExtensions.AddOpenApiOperationTransformer(this TBuilder builder, System.Func! transformer) -> TBuilder +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument? +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument? +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 091bb4931098..a358f56d08a9 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -77,7 +77,7 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop document.Paths = await GetOpenApiPathsAsync(document, scopedServiceProvider, operationTransformers, schemaTransformers, cancellationToken); try { - await ApplyTransformersAsync(document, scopedServiceProvider, cancellationToken); + await ApplyTransformersAsync(document, scopedServiceProvider, schemaTransformers, cancellationToken); } finally @@ -95,13 +95,15 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop return document; } - private async Task ApplyTransformersAsync(OpenApiDocument document, IServiceProvider scopedServiceProvider, CancellationToken cancellationToken) + private async Task ApplyTransformersAsync(OpenApiDocument document, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) { var documentTransformerContext = new OpenApiDocumentTransformerContext { DocumentName = documentName, ApplicationServices = scopedServiceProvider, DescriptionGroups = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items, + Document = document, + SchemaTransformers = schemaTransformers }; // Use index-based for loop to avoid allocating an enumerator with a foreach. for (var i = 0; i < _options.DocumentTransformers.Count; i++) @@ -271,6 +273,8 @@ private async Task> GetOperationsAsy DocumentName = documentName, Description = description, ApplicationServices = scopedServiceProvider, + Document = document, + SchemaTransformers = schemaTransformers }; _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, operationContext); diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 0714e77fee35..b2dbbfe3fd90 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -118,7 +118,7 @@ internal sealed class OpenApiSchemaService( } }; - internal async Task GetOrCreateSchemaAsync(OpenApiDocument document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + internal async Task GetOrCreateUnresolvedSchemaAsync(OpenApiDocument? document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) { var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription && parameterDescription.ModelMetadata.PropertyName is null @@ -133,7 +133,13 @@ internal async Task GetOrCreateSchemaAsync(OpenApiDocument docum var deserializedSchema = JsonSerializer.Deserialize(schemaAsJsonObject, _jsonSchemaContext.OpenApiJsonSchema); Debug.Assert(deserializedSchema != null, "The schema should have been deserialized successfully and materialize a non-null value."); var schema = deserializedSchema.Schema; - await ApplySchemaTransformersAsync(schema, type, scopedServiceProvider, schemaTransformers, parameterDescription, cancellationToken); + await ApplySchemaTransformersAsync(document, schema, type, scopedServiceProvider, schemaTransformers, parameterDescription, cancellationToken); + return schema; + } + + internal async Task GetOrCreateSchemaAsync(OpenApiDocument document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + { + var schema = await GetOrCreateUnresolvedSchemaAsync(document, type, scopedServiceProvider, schemaTransformers, parameterDescription, cancellationToken); return ResolveReferenceForSchema(document, schema); } @@ -229,7 +235,7 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen return schema; } - internal async Task ApplySchemaTransformersAsync(IOpenApiSchema schema, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + internal async Task ApplySchemaTransformersAsync(OpenApiDocument? document, IOpenApiSchema schema, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) { if (schemaTransformers.Length == 0) { @@ -242,7 +248,9 @@ internal async Task ApplySchemaTransformersAsync(IOpenApiSchema schema, Type typ JsonTypeInfo = jsonTypeInfo, JsonPropertyInfo = null, ParameterDescription = parameterDescription, - ApplicationServices = scopedServiceProvider + ApplicationServices = scopedServiceProvider, + Document = document, + SchemaTransformers = schemaTransformers }; for (var i = 0; i < schemaTransformers.Length; i++) { diff --git a/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs index c47638bedf93..4445d8591b63 100644 --- a/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs +++ b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi; @@ -24,4 +27,33 @@ public sealed class OpenApiDocumentTransformerContext /// Gets the application services associated with current document. /// public required IServiceProvider ApplicationServices { get; init; } + + internal IOpenApiSchemaTransformer[] SchemaTransformers { get; init; } = []; + + // Internal because we expect users to interact with the `Document` provided in + // the `IOpenApiDocumentTransformer` itself instead of the context object. + internal OpenApiDocument? Document { get; init; } + + /// + /// Gets or creates an for the specified type. Augments + /// the schema with any s that are registered + /// on the document. If is not null, the schema will be + /// augmented with the information. + /// + /// The type for which the schema is being created. + /// An optional parameter description to augment the schema. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation, with a value of type . + public Task GetOrCreateSchemaAsync(Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + { + Debug.Assert(Document is not null, "Document should have been initialized by framework."); + var schemaService = ApplicationServices.GetRequiredKeyedService(DocumentName); + return schemaService.GetOrCreateUnresolvedSchemaAsync( + document: Document, + type: type, + parameterDescription: parameterDescription, + scopedServiceProvider: ApplicationServices, + schemaTransformers: SchemaTransformers, + cancellationToken: cancellationToken); + } } diff --git a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs index 49d76a0191e6..8e070cc2c92f 100644 --- a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs +++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi; @@ -24,4 +26,33 @@ public sealed class OpenApiOperationTransformerContext /// Gets the application services associated with the current document the target operation is in. /// public required IServiceProvider ApplicationServices { get; init; } + + /// + /// Gets the OpenAPI document the current endpoint belongs to. + /// + public OpenApiDocument? Document { get; init; } + + internal IOpenApiSchemaTransformer[] SchemaTransformers { get; init; } = []; + + /// + /// Gets or creates an for the specified type. Augments + /// the schema with any s that are registered + /// on the document. If is not null, the schema will be + /// augmented with the information. + /// + /// The type for which the schema is being created. + /// An optional parameter description to augment the schema. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation, with a value of type . + public Task GetOrCreateSchemaAsync(Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + { + var schemaService = ApplicationServices.GetRequiredKeyedService(DocumentName); + return schemaService.GetOrCreateUnresolvedSchemaAsync( + document: Document, + type: type, + parameterDescription: parameterDescription, + scopedServiceProvider: ApplicationServices, + schemaTransformers: SchemaTransformers, + cancellationToken: cancellationToken); + } } diff --git a/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs index 94a03e679639..4d8abc8d911e 100644 --- a/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs +++ b/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi; @@ -41,6 +43,11 @@ public sealed class OpenApiSchemaTransformerContext /// public required IServiceProvider ApplicationServices { get; init; } + /// + /// Gets the OpenAPI document the current schema belongs to. + /// + public OpenApiDocument? Document { get; init; } + // Expose internal setters for the properties that only allow initializations to avoid allocating // new instances of the context for each sub-schema transformation. internal void UpdateJsonTypeInfo(JsonTypeInfo jsonTypeInfo, JsonPropertyInfo? jsonPropertyInfo) @@ -48,4 +55,28 @@ internal void UpdateJsonTypeInfo(JsonTypeInfo jsonTypeInfo, JsonPropertyInfo? js _jsonTypeInfo = jsonTypeInfo; _jsonPropertyInfo = jsonPropertyInfo; } + + internal IOpenApiSchemaTransformer[] SchemaTransformers { get; init; } = []; + + /// + /// Gets or creates an for the specified type. Augments + /// the schema with any s that are registered + /// on the document. If is not null, the schema will be + /// augmented with the information. + /// + /// The type for which the schema is being created. + /// An optional parameter description to augment the schema. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation, with a value of type . + public Task GetOrCreateSchemaAsync(Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + { + var schemaService = ApplicationServices.GetRequiredKeyedService(DocumentName); + return schemaService.GetOrCreateUnresolvedSchemaAsync( + document: Document, + type: type, + parameterDescription: parameterDescription, + scopedServiceProvider: ApplicationServices, + schemaTransformers: SchemaTransformers, + cancellationToken: cancellationToken); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/CustomSchemaTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/CustomSchemaTransformerTests.cs new file mode 100644 index 000000000000..44f41f3bd0bf --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/CustomSchemaTransformerTests.cs @@ -0,0 +1,545 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Models.References; + +public class CustomSchemaTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task CustomSchemaTransformer_CanInsertSchemaIntoDocumentFromOperationTransformer() + { + // Arrange + var builder = CreateBuilder(); + builder.MapGet("/error", () => { }); + + // Act + var options = new OpenApiOptions(); + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + if (context.Description.RelativePath == "error") + { + var errorSchema = await context.GetOrCreateSchemaAsync(typeof(ProblemDetails), cancellationToken: cancellationToken); + context.Document.AddComponent("Error", errorSchema); + operation.Responses["500"] = new OpenApiResponse + { + Description = "Error", + Content = + { + ["application/problem+json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("Error", context.Document), + }, + }, + }; + } + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + var schema = Assert.Single(document.Components.Schemas); + Assert.Equal("Error", schema.Key); + var targetSchema = Assert.IsType(schema.Value); + Assert.Collection(targetSchema.Properties, + property => + { + Assert.Equal("type", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("title", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("status", property.Key); + Assert.Equal(JsonSchemaType.Integer | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("detail", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("instance", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }); + }); + } + + [Fact] + public async Task GetOrCreateSchema_AddsSchemasForMultipleResponseTypes() + { + // Arrange + var builder = CreateBuilder(); + builder.MapGet("/api", () => TypedResults.Ok(new Todo(1, "Task", false, DateTime.Now))); + + // Act + var options = new OpenApiOptions(); + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + var todoSchema = await context.GetOrCreateSchemaAsync(typeof(Todo), cancellationToken: cancellationToken); + context.Document.AddComponent("Todo2", todoSchema); + + var errorSchema = await context.GetOrCreateSchemaAsync(typeof(ProblemDetails), cancellationToken: cancellationToken); + context.Document.AddComponent("ProblemDetails", errorSchema); + + // Add both success and error responses + operation.Responses["200"] = new OpenApiResponse + { + Description = "Success", + Content = + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("Todo2", context.Document), + }, + }, + }; + + operation.Responses["400"] = new OpenApiResponse + { + Description = "Bad Request", + Content = + { + ["application/problem+json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("ProblemDetails", context.Document), + }, + }, + }; + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + Assert.Collection(document.Components.Schemas.Keys, + key => Assert.Equal("ProblemDetails", key), + key => Assert.Equal("Todo", key), + key => Assert.Equal("Todo2", key)); + + var todoSchema = document.Components.Schemas["Todo2"]; + Assert.Equal(4, todoSchema.Properties.Count); + Assert.True(todoSchema.Properties.ContainsKey("id")); + Assert.True(todoSchema.Properties.ContainsKey("title")); + Assert.True(todoSchema.Properties.ContainsKey("completed")); + Assert.True(todoSchema.Properties.ContainsKey("createdAt")); + }); + } + + [Fact] + public async Task GetOrCreateSchema_CanBeUsedInSchemaTransformer() + { + // Arrange + var builder = CreateBuilder(); + builder.MapPost("/shape", (Shape shape) => new Triangle { Hypotenuse = 25 }); + + // Act + var options = new OpenApiOptions(); + options.AddSchemaTransformer(async (schema, context, cancellationToken) => + { + // Only transform the base Shape class schema + if (context.JsonTypeInfo.Type == typeof(Shape)) + { + // Create an example schema to reference in our documentation + var exampleSchema = await context.GetOrCreateSchemaAsync(typeof(Triangle), cancellationToken: cancellationToken); + context.Document.AddComponent("TriangleExample", exampleSchema); + + // Add a reference to the example in the shape schema + schema.Extensions["x-example-component"] = new OpenApiAny("#/components/schemas/TriangleExample"); + schema.Description = "A shape with an example reference"; + } + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + // Verify we have our TriangleExample component + Assert.True(document.Components.Schemas.ContainsKey("TriangleExample")); + + // Verify the base Shape schema has our extension + var shapeSchema = document.Components.Schemas["Shape"]; + + Assert.NotNull(shapeSchema); + Assert.Equal("A shape with an example reference", shapeSchema.Description); + Assert.True(shapeSchema.Extensions.ContainsKey("x-example-component")); + }); + } + + [Fact] + public async Task GetOrCreateSchema_CreatesDifferentSchemaForSameTypeWithDifferentParameterDescription() + { + // Arrange + var builder = CreateBuilder(); + builder.MapPost("/items", (int id, [FromQuery] int limit) => { }); + + // Act + var options = new OpenApiOptions(); + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + // Get the parameter descriptions associated with the type + var idParam = context.Description.ParameterDescriptions.FirstOrDefault(p => p.Name == "id"); + var limitParam = context.Description.ParameterDescriptions.FirstOrDefault(p => p.Name == "limit"); + + // Get schemas for same type but different parameter descriptions + var idSchema = await context.GetOrCreateSchemaAsync(typeof(int), idParam, cancellationToken); + var limitSchema = await context.GetOrCreateSchemaAsync(typeof(int), limitParam, cancellationToken); + + // Add schemas to document + context.Document.AddComponent("IdParameter", idSchema); + context.Document.AddComponent("LimitParameter", limitSchema); + + // Use schemas in custom parameter + operation.Parameters.Add(new OpenApiParameter + { + Name = "custom-id", + In = ParameterLocation.Path, + Schema = new OpenApiSchemaReference("IdParameter", context.Document) + }); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "custom-limit", + In = ParameterLocation.Query, + Schema = new OpenApiSchemaReference("LimitParameter", context.Document) + }); + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + Assert.Equal(2, document.Components.Schemas.Count); + Assert.Contains("IdParameter", document.Components.Schemas.Keys); + Assert.Contains("LimitParameter", document.Components.Schemas.Keys); + + // Both schemas should have the same base type properties + var idSchema = document.Components.Schemas["IdParameter"]; + var limitSchema = document.Components.Schemas["LimitParameter"]; + + Assert.Equal(JsonSchemaType.Integer, idSchema.Type); + Assert.Equal(JsonSchemaType.Integer, limitSchema.Type); + + // Operation should now have 4 parameters (2 original + 2 custom) + var operation = document.Paths["/items"].Operations[OperationType.Post]; + Assert.Equal(4, operation.Parameters.Count); + }); + } + + [Fact] + public async Task GetOrCreateSchema_WorksWithNestedTypes() + { + // Arrange + var builder = CreateBuilder(); + builder.MapGet("/api", () => new { }); + + // Act + var options = new OpenApiOptions(); + options.AddDocumentTransformer(async (document, context, cancellationToken) => + { + // Generate schema for a complex nested type + var nestedTypeSchema = await context.GetOrCreateSchemaAsync(typeof(NestedContainer), cancellationToken: cancellationToken); + document.AddComponent("NestedContainer", nestedTypeSchema); + + // Add a new path that uses this schema + var path = new OpenApiPathItem(); + var operation = new OpenApiOperation + { + OperationId = "GetNestedContainer", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "Success", + Content = + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("NestedContainer", document) + } + } + } + } + }; + + path.Operations[OperationType.Get] = operation; + document.Paths["/nested"] = path; + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + // Verify the schema was added + Assert.True(document.Components.Schemas.ContainsKey("NestedContainer")); + + // Verify the path was added + Assert.True(document.Paths.ContainsKey("/nested")); + + // Verify the schema structure + var containerSchema = document.Components.Schemas["NestedContainer"]; + Assert.True(containerSchema.Properties.ContainsKey("items")); + + // Verify array type for items + var itemsSchema = containerSchema.Properties["items"]; + Assert.Equal(JsonSchemaType.Array | JsonSchemaType.Null, itemsSchema.Type); + + // Component schemas are not generated for nested types + Assert.False(document.Components.Schemas.ContainsKey("NestedItem")); + Assert.True(itemsSchema.Items is OpenApiSchema); + }); + } + + [Fact] + public async Task GetOrCreateSchemaAsync_AppliesOtherSchemaTransformers() + { + // Arrange + var builder = CreateBuilder(); + builder.MapGet("/product", () => new { }); + + // Define a transformation flag that we'll check later + var transformerApplied = false; + var nestedTransformerApplied = false; + + // Act + var options = new OpenApiOptions(); + + // Add a schema transformer that will mark all Product schemas as required + options.AddSchemaTransformer((schema, context, cancellationToken) => + { + if (context.JsonTypeInfo.Type == typeof(Product)) + { + schema.Required.Add("name"); + schema.Required.Add("price"); + transformerApplied = true; + } + + if (context.JsonTypeInfo.Type == typeof(Category)) + { + schema.Description = "Transformed category description"; + nestedTransformerApplied = true; + } + + return Task.CompletedTask; + }); + + // Add an operation transformer that uses GetOrCreateSchemaAsync + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + // Generate a schema for Product + var productSchema = await context.GetOrCreateSchemaAsync(typeof(Product), cancellationToken: cancellationToken); + + // Add it to the document + context.Document.AddComponent("ProductSchema", productSchema); + + // Use it in the response + operation.Responses["200"] = new OpenApiResponse + { + Description = "A product", + Content = + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("ProductSchema", context.Document) + } + } + }; + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + // Verify schema was created + Assert.True(document.Components.Schemas.ContainsKey("ProductSchema")); + + // Get the schema + var schema = document.Components.Schemas["ProductSchema"]; + + // Verify schema properties + Assert.True(schema.Properties.ContainsKey("name")); + Assert.True(schema.Properties.ContainsKey("price")); + Assert.True(schema.Properties.ContainsKey("category")); + + // Verify transformer was applied - it should have added required properties + Assert.True(transformerApplied); + Assert.Contains("name", schema.Required); + Assert.Contains("price", schema.Required); + + // Verify transformer was also applied to nested schema + var categoryProperty = schema.Properties["category"]; + Assert.True(nestedTransformerApplied); + + Assert.Equal("Transformed category description", categoryProperty.Description); + }); + } + + [Fact] + public async Task GetOrCreateSchemaAsync_HandlesConcurrentRequests() + { + // Arrange + var builder = CreateBuilder(); + builder.MapGet("/concurrent", () => new { }); + + // Act + var options = new OpenApiOptions(); + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + // Generate schema concurrently for the same type + var tasks = new Task[5]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = context.GetOrCreateSchemaAsync(typeof(ComplexType), cancellationToken: cancellationToken); + } + + // Wait for all tasks to complete + var schemas = await Task.WhenAll(tasks); + + // All schemas should be the same instance when added to components + for (var i = 0; i < schemas.Length; i++) + { + context.Document.AddComponent($"Schema{i}", schemas[i]); + } + + // Use one of them in the response + operation.Responses["200"] = new OpenApiResponse + { + Description = "Concurrent schema generation test", + Content = + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("Schema0", context.Document) + } + } + }; + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + // All schemas should be generated + for (var i = 0; i < 5; i++) + { + Assert.True(document.Components.Schemas.ContainsKey($"Schema{i}")); + // They should all have the same structure + var schema = document.Components.Schemas[$"Schema{i}"]; + + Assert.True(schema.Properties.ContainsKey("id")); + Assert.True(schema.Properties.ContainsKey("name")); + Assert.True(schema.Properties.ContainsKey("createdAt")); + Assert.True(schema.Properties.ContainsKey("tags")); + Assert.True(schema.Properties.ContainsKey("metadata")); + } + }); + } + + [Fact] + public async Task GetOrCreateSchemaAsync_RespectsJsonSerializerOptions() + { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.Configure(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + var builder = CreateBuilder(); + builder.MapGet("/customjson", () => new { }); + + // Act + var options = new OpenApiOptions(); + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + // Generate schema that should respect JSON naming policy + var userSchema = await context.GetOrCreateSchemaAsync(typeof(UserWithJsonOptions), cancellationToken: cancellationToken); + context.Document.AddComponent("User", userSchema); + + operation.Responses["200"] = new OpenApiResponse + { + Description = "User with custom JSON options", + Content = + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("User", context.Document) + } + } + }; + }); + + // Assert + await VerifyOpenApiDocument(builder, options, (document) => + { + // Verify schema was created + Assert.True(document.Components.Schemas.ContainsKey("User")); + + // Get the schema + var schema = document.Components.Schemas["User"]; + + // Property names should be camelCase due to the naming policy + Assert.True(schema.Properties.ContainsKey("firstName")); + Assert.True(schema.Properties.ContainsKey("lastName")); + Assert.True(schema.Properties.ContainsKey("dateOfBirth")); + + // The ignored property should not be in the schema + Assert.False(schema.Properties.ContainsKey("temporaryData")); + }); + } + + // For the nested types test + internal class NestedContainer + { + public List Items { get; set; } = []; + public string Name { get; set; } = "Container"; + } + + internal class NestedItem + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + // Supporting classes for the test + internal class Product + { + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public Category Category { get; set; } = new(); + } + + internal class Category + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + internal class ComplexType + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public List Tags { get; set; } = []; + public Dictionary Metadata { get; set; } = []; + } + + internal class UserWithJsonOptions + { + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateOnly DateOfBirth { get; set; } + + [JsonIgnore] + public string TemporaryData { get; set; } = string.Empty; + } + +} +