diff --git a/AspNetCore.sln b/AspNetCore.sln index 7eba541ea0a..a9c2857c418 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1788,7 +1788,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NotReferencedInWasmCodePack EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.WasmRemoteAuthentication", "src\Components\test\testassets\Components.WasmRemoteAuthentication\Components.WasmRemoteAuthentication.csproj", "{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "src\OpenApi\sample\Sample.csproj", "{6DEC24A8-A166-432F-8E3B-58FFCDA92F52}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample", "src\OpenApi\sample\Sample.csproj", "{6DEC24A8-A166-432F-8E3B-58FFCDA92F52}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hybrid", "Hybrid", "{2D64CA23-6E81-488E-A7D3-9BDF87240098}" EndProject @@ -1802,7 +1802,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Cachin EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{9DC6B242-457B-4767-A84B-C3D23B76C642}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Microbenchmarks", "src\OpenApi\perf\Microbenchmarks\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", "{D53F0EF7-0CDC-49B4-AA2D-229901B0A734}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OpenApi.Microbenchmarks", "src\OpenApi\perf\Microbenchmarks\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", "{D53F0EF7-0CDC-49B4-AA2D-229901B0A734}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KeyManagementSimulator", "src\DataProtection\samples\KeyManagementSimulator\KeyManagementSimulator.csproj", "{5B5F86CC-3598-463C-9F9B-F78FBB6642F4}" EndProject diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf index 1f85792cc33..1358a85267a 100644 --- a/src/OpenApi/OpenApi.slnf +++ b/src/OpenApi/OpenApi.slnf @@ -2,15 +2,19 @@ "solution": { "path": "..\\..\\AspNetCore.sln", "projects": [ + "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", + "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", + "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", + "src\\OpenApi\\perf\\Microbenchmarks\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", + "src\\OpenApi\\sample\\Sample.csproj", "src\\OpenApi\\src\\Microsoft.AspNetCore.OpenApi.csproj", "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj", - "src\\OpenApi\\sample\\Sample.csproj", - "src\\OpenApi\\perf\\Microbenchmarks\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj" + "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj" ] } } diff --git a/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs index 8b8fda46709..42b64a83fba 100644 --- a/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs +++ b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs @@ -21,16 +21,27 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase public int TransformerCount { get; set; } private readonly IEndpointRouteBuilder _builder = CreateBuilder(); - private readonly OpenApiOptions _options = new OpenApiOptions(); + private readonly OpenApiOptions _options = new(); private OpenApiDocumentService _documentService; + [GlobalSetup(Target = nameof(ActivatedOperationTransformer))] + public void ActivatedOperationTransformer_Setup() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.AddOperationTransformer(); + } + _documentService = CreateDocumentService(_builder, _options); + } + [GlobalSetup(Target = nameof(OperationTransformerAsDelegate))] public void OperationTransformerAsDelegate_Setup() { _builder.MapGet("/", () => { }); for (var i = 0; i <= TransformerCount; i++) { - _options.UseOperationTransformer((operation, context, token) => + _options.AddOperationTransformer((operation, context, token) => { operation.Description = "New Description"; return Task.CompletedTask; @@ -45,7 +56,7 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase _builder.MapGet("/", () => { }); for (var i = 0; i <= TransformerCount; i++) { - _options.UseTransformer(); + _options.AddDocumentTransformer(); } _documentService = CreateDocumentService(_builder, _options); } @@ -56,7 +67,7 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase _builder.MapGet("/", () => { }); for (var i = 0; i <= TransformerCount; i++) { - _options.UseTransformer((document, context, token) => + _options.AddDocumentTransformer((document, context, token) => { document.Info.Description = "New Description"; return Task.CompletedTask; @@ -65,13 +76,24 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase _documentService = CreateDocumentService(_builder, _options); } - [GlobalSetup(Target = nameof(SchemaTransformer))] + [GlobalSetup(Target = nameof(ActivatedSchemaTransformer))] + public void ActivatedSchemaTransformer_Setup() + { + _builder.MapPost("/", (Todo todo) => todo); + for (var i = 0; i <= TransformerCount; i++) + { + _options.AddSchemaTransformer(); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [GlobalSetup(Target = nameof(SchemaTransformerAsDelegate))] public void SchemaTransformer_Setup() { _builder.MapPost("/", (Todo todo) => todo); for (var i = 0; i <= TransformerCount; i++) { - _options.UseSchemaTransformer((schema, context, token) => + _options.AddSchemaTransformer((schema, context, token) => { if (context.Type == typeof(Todo) && context.ParameterDescription != null) { @@ -87,6 +109,12 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase _documentService = CreateDocumentService(_builder, _options); } + [Benchmark] + public async Task ActivatedOperationTransformer() + { + await _documentService.GetOpenApiDocumentAsync(); + } + [Benchmark] public async Task OperationTransformerAsDelegate() { @@ -106,12 +134,18 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase } [Benchmark] - public async Task SchemaTransformer() + public async Task ActivatedSchemaTransformer() { await _documentService.GetOpenApiDocumentAsync(); } - private class ActivatedTransformer : IOpenApiDocumentTransformer + [Benchmark] + public async Task SchemaTransformerAsDelegate() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + private class DocumentTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { @@ -119,4 +153,29 @@ public class TransformersBenchmark : OpenApiDocumentServiceTestBase return Task.CompletedTask; } } + + private class OperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + } + } + + private class SchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.Type == typeof(Todo) && context.ParameterDescription != null) + { + schema.Extensions["x-my-extension"] = new OpenApiString(context.ParameterDescription.Name); + } + else + { + schema.Extensions["x-my-extension"] = new OpenApiString("response"); + } + return Task.CompletedTask; + } + } } diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index b567bb89a56..a622780ff48 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,7 +1,6 @@ // 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.Frozen; using System.Collections.Immutable; using System.ComponentModel; using Microsoft.AspNetCore.Http.HttpResults; @@ -19,11 +18,13 @@ builder.Services.AddAuthentication().AddJwtBearer(); builder.Services.AddOpenApi("v1", options => { options.AddHeader("X-Version", "1.0"); - options.UseTransformer(); + options.AddDocumentTransformer(); }); builder.Services.AddOpenApi("v2", options => { - options.UseTransformer(new AddContactTransformer()); - options.UseTransformer((document, context, token) => { + options.AddSchemaTransformer(); + options.AddOperationTransformer(); + options.AddDocumentTransformer(new AddContactTransformer()); + options.AddDocumentTransformer((document, context, token) => { document.Info.License = new OpenApiLicense { Name = "MIT" }; return Task.CompletedTask; }); @@ -82,7 +83,8 @@ v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, v2.MapGet("/users", () => new [] { "alice", "bob" }) .WithTags("users"); -v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" })); +v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" })) + .WithName("CreateUser"); responses.MapGet("/200-add-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now)) .Produces(additionalContentTypes: "text/xml"); diff --git a/src/OpenApi/sample/Transformers/AddExternalDocsTransformer.cs b/src/OpenApi/sample/Transformers/AddExternalDocsTransformer.cs new file mode 100644 index 00000000000..6af558870e0 --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddExternalDocsTransformer.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Sample.Transformers; + +public sealed class AddExternalDocsTransformer(IConfiguration configuration) : IOpenApiOperationTransformer, IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + if (operation.OperationId is { Length: > 0 } id && + Uri.TryCreate(configuration["DocumentationBaseUrl"], UriKind.Absolute, out var baseUri)) + { + var url = new Uri(baseUri, $"/api/docs/operations/{Uri.EscapeDataString(id)}"); + + operation.ExternalDocs = new OpenApiExternalDocs + { + Description = "Documentation for this OpenAPI endpoint", + Url = url + }; + } + + return Task.CompletedTask; + } + + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (Uri.TryCreate(configuration["DocumentationBaseUrl"], UriKind.Absolute, out var baseUri)) + { + var url = new Uri(baseUri, $"/api/docs/schemas/{Uri.EscapeDataString(schema.Type)}"); + + schema.ExternalDocs = new OpenApiExternalDocs + { + Description = "Documentation for this OpenAPI schema", + Url = url + }; + } + return Task.CompletedTask; + } +} diff --git a/src/OpenApi/sample/Transformers/OperationTransformers.cs b/src/OpenApi/sample/Transformers/OperationTransformers.cs index a26f96511f2..b1427762e09 100644 --- a/src/OpenApi/sample/Transformers/OperationTransformers.cs +++ b/src/OpenApi/sample/Transformers/OperationTransformers.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Models; namespace Sample.Transformers; @@ -12,7 +12,7 @@ public static class OperationTransformers { public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue) { - return options.UseOperationTransformer((operation, context, cancellationToken) => + return options.AddOperationTransformer((operation, context, cancellationToken) => { var schema = OpenApiTypeMapper.MapTypeToOpenApiPrimitiveType(typeof(string)); schema.Default = new OpenApiString(defaultValue); diff --git a/src/OpenApi/sample/appsettings.json b/src/OpenApi/sample/appsettings.json index 10f68b8c8b4..bb6347570de 100644 --- a/src/OpenApi/sample/appsettings.json +++ b/src/OpenApi/sample/appsettings.json @@ -1,4 +1,5 @@ { + "DocumentationBaseUrl": "https://example.com", "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index e8a64d3adc5..5b9065d333b 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -1,9 +1,17 @@ #nullable enable Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer.TransformAsync(Microsoft.OpenApi.Models.OpenApiDocument! document, Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer +Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer.TransformAsync(Microsoft.OpenApi.Models.OpenApiOperation! operation, Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.OpenApi.IOpenApiSchemaTransformer +Microsoft.AspNetCore.OpenApi.IOpenApiSchemaTransformer.TransformAsync(Microsoft.OpenApi.Models.OpenApiSchema! schema, Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Description.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription! Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Description.init -> void Microsoft.AspNetCore.OpenApi.OpenApiOptions +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddOperationTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddOperationTransformer() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddSchemaTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiSchemaTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddSchemaTransformer() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! Microsoft.AspNetCore.OpenApi.OpenApiOptions.CreateSchemaReferenceId.get -> System.Func! Microsoft.AspNetCore.OpenApi.OpenApiOptions.CreateSchemaReferenceId.set -> void Microsoft.AspNetCore.OpenApi.OpenApiOptions.DocumentName.get -> string! @@ -12,11 +20,11 @@ Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.get -> Microsoft.Open Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.set -> void Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.get -> System.Func! Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.set -> void -Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseOperationTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! -Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseSchemaTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! -Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! -Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! -Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddOperationTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddSchemaTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddDocumentTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddDocumentTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.AddDocumentTransformer() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ApplicationServices.get -> System.IServiceProvider! Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ApplicationServices.init -> void diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index e92fc43bf07..cc6c04d3610 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -36,9 +36,9 @@ internal sealed class OpenApiDocumentService( { private readonly OpenApiOptions _options = optionsMonitor.Get(documentName); private readonly OpenApiSchemaService _componentService = serviceProvider.GetRequiredKeyedService(documentName); - private readonly IOpenApiDocumentTransformer _schemaReferenceTransformer = new OpenApiSchemaReferenceTransformer(); + private readonly OpenApiSchemaReferenceTransformer _schemaReferenceTransformer = new(); - private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true }; + private static readonly OpenApiEncoding _defaultFormEncoding = new() { Style = ParameterStyle.Form, Explode = true }; /// /// Cache of instances keyed by the @@ -47,7 +47,7 @@ internal sealed class OpenApiDocumentService( /// operations, API descriptions, and their respective transformer contexts. /// private readonly Dictionary _operationTransformerContextCache = new(); - private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK }; + private static readonly ApiResponseType _defaultApiResponseType = new() { StatusCode = StatusCodes.Status200OK }; internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) => _operationTransformerContextCache.TryGetValue(descriptionId, out context); @@ -86,6 +86,41 @@ internal sealed class OpenApiDocumentService( await _schemaReferenceTransformer.TransformAsync(document, documentTransformerContext, cancellationToken); } + internal async Task ForEachOperationAsync( + OpenApiDocument document, + Func callback, + CancellationToken cancellationToken) + { + foreach (var pathItem in document.Paths.Values) + { + for (var i = 0; i < OpenApiConstants.OperationTypes.Length; i++) + { + var operationType = OpenApiConstants.OperationTypes[i]; + if (!pathItem.Operations.TryGetValue(operationType, out var operation)) + { + continue; + } + + if (operation.Extensions.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionIdExtension) && + descriptionIdExtension is ScrubbedOpenApiAny { Value: string descriptionId } && + TryGetCachedOperationTransformerContext(descriptionId, out var operationContext)) + { + await callback(operation, operationContext, cancellationToken); + } + else + { + // If the cached operation transformer context was not found, throw an exception. + // This can occur if the `x-aspnetcore-id` extension attribute was removed by the + // user in another operation transformer or if the lookup for operation transformer + // context resulted in a cache miss. As an alternative here, we could just to implement + // the "slow-path" and look up the ApiDescription associated with the OpenApiOperation + // using the OperationType and given path, but we'll avoid this for now. + throw new InvalidOperationException("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute."); + } + } + } + } + // Note: Internal for testing. internal OpenApiInfo GetOpenApiInfo() { @@ -137,13 +172,23 @@ internal sealed class OpenApiDocumentService( { var operation = await GetOperationAsync(description, capturedTags, cancellationToken); operation.Extensions.Add(OpenApiConstants.DescriptionId, new ScrubbedOpenApiAny(description.ActionDescriptor.Id)); - _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, new OpenApiOperationTransformerContext + + var operationContext = new OpenApiOperationTransformerContext { DocumentName = documentName, Description = description, ApplicationServices = serviceProvider, - }); + }; + + _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, operationContext); operations[description.GetOperationType()] = operation; + + // Use index-based for loop to avoid allocating an enumerator with a foreach. + for (var i = 0; i < _options.OperationTransformers.Count; i++) + { + var transformer = _options.OperationTransformers[i]; + await transformer.TransformAsync(operation, operationContext, cancellationToken); + } } return operations; } diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index a8ee4ec93f1..7b900b53c9c 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -15,7 +15,8 @@ namespace Microsoft.AspNetCore.OpenApi; public sealed class OpenApiOptions { internal readonly List DocumentTransformers = []; - internal readonly List> SchemaTransformers = []; + internal readonly List OperationTransformers = []; + internal readonly List SchemaTransformers = []; /// /// A default implementation for creating a schema reference ID for a given . @@ -62,7 +63,7 @@ public sealed class OpenApiOptions /// /// The type of the to instantiate. /// The instance for further customization. - public OpenApiOptions UseTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>() + public OpenApiOptions AddDocumentTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>() where TTransformerType : IOpenApiDocumentTransformer { DocumentTransformers.Add(new TypeBasedOpenApiDocumentTransformer(typeof(TTransformerType))); @@ -74,9 +75,9 @@ public sealed class OpenApiOptions /// /// The instance to use. /// The instance for further customization. - public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer) + public OpenApiOptions AddDocumentTransformer(IOpenApiDocumentTransformer transformer) { - ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + ArgumentNullException.ThrowIfNull(transformer); DocumentTransformers.Add(transformer); return this; @@ -87,24 +88,74 @@ public sealed class OpenApiOptions /// /// The delegate representing the document transformer. /// The instance for further customization. - public OpenApiOptions UseTransformer(Func transformer) + public OpenApiOptions AddDocumentTransformer(Func transformer) { - ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + ArgumentNullException.ThrowIfNull(transformer); DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); return this; } + /// + /// Registers a new operation transformer on the current instance. + /// + /// The type of the to instantiate. + /// The instance for further customization. + public OpenApiOptions AddOperationTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>() + where TTransformerType : IOpenApiOperationTransformer + { + OperationTransformers.Add(new TypeBasedOpenApiOperationTransformer(typeof(TTransformerType))); + return this; + } + + /// + /// Registers a given instance of on the current instance. + /// + /// The instance to use. + /// The instance for further customization. + public OpenApiOptions AddOperationTransformer(IOpenApiOperationTransformer transformer) + { + ArgumentNullException.ThrowIfNull(transformer); + + OperationTransformers.Add(transformer); + return this; + } + /// /// Registers a given delegate as an operation transformer on the current instance. /// /// The delegate representing the operation transformer. /// The instance for further customization. - public OpenApiOptions UseOperationTransformer(Func transformer) + public OpenApiOptions AddOperationTransformer(Func transformer) { - ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + ArgumentNullException.ThrowIfNull(transformer); - DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); + OperationTransformers.Add(new DelegateOpenApiOperationTransformer(transformer)); + return this; + } + + /// + /// Registers a new schema transformer on the current instance. + /// + /// The type of the to instantiate. + /// The instance for further customization. + public OpenApiOptions AddSchemaTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>() + where TTransformerType : IOpenApiSchemaTransformer + { + SchemaTransformers.Add(new TypeBasedOpenApiSchemaTransformer(typeof(TTransformerType))); + return this; + } + + /// + /// Registers a given instance of on the current instance. + /// + /// The instance to use. + /// The instance for further customization. + public OpenApiOptions AddSchemaTransformer(IOpenApiSchemaTransformer transformer) + { + ArgumentNullException.ThrowIfNull(transformer); + + SchemaTransformers.Add(transformer); return this; } @@ -113,11 +164,11 @@ public sealed class OpenApiOptions /// /// The delegate representing the schema transformer. /// The instance for further customization. - public OpenApiOptions UseSchemaTransformer(Func transformer) + public OpenApiOptions AddSchemaTransformer(Func transformer) { - ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + ArgumentNullException.ThrowIfNull(transformer); - SchemaTransformers.Add(transformer); + SchemaTransformers.Add(new DelegateOpenApiSchemaTransformer(transformer)); return this; } } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 750037807d5..60751c4d7e0 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -154,7 +154,7 @@ internal sealed class OpenApiSchemaService( for (var i = 0; i < _openApiOptions.SchemaTransformers.Count; i++) { var transformer = _openApiOptions.SchemaTransformers[i]; - await transformer(schema, context, cancellationToken); + await transformer.TransformAsync(schema, context, cancellationToken); } } diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs index 56af755acf3..3a31bbe7a2b 100644 --- a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -31,34 +31,10 @@ internal sealed class DelegateOpenApiDocumentTransformer : IOpenApiDocumentTrans if (_operationTransformer != null) { var documentService = context.ApplicationServices.GetRequiredKeyedService(context.DocumentName); - foreach (var pathItem in document.Paths.Values) - { - for (var i = 0; i < OpenApiConstants.OperationTypes.Length; i++) - { - var operationType = OpenApiConstants.OperationTypes[i]; - if (!pathItem.Operations.TryGetValue(operationType, out var operation)) - { - continue; - } - - if (operation.Extensions.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionIdExtension) && - descriptionIdExtension is ScrubbedOpenApiAny { Value: string descriptionId } && - documentService.TryGetCachedOperationTransformerContext(descriptionId, out var operationContext)) - { - await _operationTransformer(operation, operationContext, cancellationToken); - } - else - { - // If the cached operation transformer context was not found, throw an exception. - // This can occur if the `x-aspnetcore-id` extension attribute was removed by the - // user in another operation transformer or if the lookup for operation transformer - // context resulted in a cache miss. As an alternative here, we could just to implement - // the "slow-path" and look up the ApiDescription associated with the OpenApiOperation - // using the OperationType and given path, but we'll avoid this for now. - throw new InvalidOperationException("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute."); - } - } - } + await documentService.ForEachOperationAsync( + document, + async (operation, operationContext, token) => await _operationTransformer(operation, operationContext, token), + cancellationToken); } } } diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiOperationTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiOperationTransformer.cs new file mode 100644 index 00000000000..cf7c7ac4ae7 --- /dev/null +++ b/src/OpenApi/src/Transformers/DelegateOpenApiOperationTransformer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class DelegateOpenApiOperationTransformer : IOpenApiOperationTransformer +{ + private readonly Func _transformer; + + public DelegateOpenApiOperationTransformer(Func transformer) + { + _transformer = transformer; + } + + public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + await _transformer(operation, context, cancellationToken); + } +} diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiSchemaTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiSchemaTransformer.cs new file mode 100644 index 00000000000..bfe25b2297f --- /dev/null +++ b/src/OpenApi/src/Transformers/DelegateOpenApiSchemaTransformer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class DelegateOpenApiSchemaTransformer : IOpenApiSchemaTransformer +{ + private readonly Func _transformer; + + public DelegateOpenApiSchemaTransformer(Func transformer) + { + _transformer = transformer; + } + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + await _transformer(schema, context, cancellationToken); + } +} diff --git a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs index c12f249a193..5c92a2700e4 100644 --- a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs +++ b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs @@ -17,5 +17,5 @@ public interface IOpenApiDocumentTransformer /// The associated with the . /// The cancellation token to use. /// The task object representing the asynchronous operation. - public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken); + Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken); } diff --git a/src/OpenApi/src/Transformers/IOpenApiOperationTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiOperationTransformer.cs new file mode 100644 index 00000000000..3d5227342b3 --- /dev/null +++ b/src/OpenApi/src/Transformers/IOpenApiOperationTransformer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents a transformer that can be used to modify an OpenAPI operation. +/// +public interface IOpenApiOperationTransformer +{ + /// + /// Transforms the specified OpenAPI operation. + /// + /// The to modify. + /// The associated with the . + /// The cancellation token to use. + /// The task object representing the asynchronous operation. + Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken); +} diff --git a/src/OpenApi/src/Transformers/IOpenApiSchemaTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiSchemaTransformer.cs new file mode 100644 index 00000000000..e971ded4e37 --- /dev/null +++ b/src/OpenApi/src/Transformers/IOpenApiSchemaTransformer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents a transformer that can be used to modify an OpenAPI schema. +/// +public interface IOpenApiSchemaTransformer +{ + /// + /// Transforms the specified OpenAPI schema. + /// + /// The to modify. + /// The associated with the . + /// The cancellation token to use. + /// The task object representing the asynchronous operation. + Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken); +} diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiOperationTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiOperationTransformer.cs new file mode 100644 index 00000000000..6d2251a5ccf --- /dev/null +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiOperationTransformer.cs @@ -0,0 +1,44 @@ +// 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 System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class TypeBasedOpenApiOperationTransformer : IOpenApiOperationTransformer +{ + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + private readonly Type _transformerType; + private readonly ObjectFactory _transformerFactory; + + internal TypeBasedOpenApiOperationTransformer([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type transformerType) + { + _transformerType = transformerType; + _transformerFactory = ActivatorUtilities.CreateFactory(_transformerType, []); + } + + public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiOperationTransformer; + Debug.Assert(transformer is not null, $"The type {_transformerType} does not implement {nameof(IOpenApiOperationTransformer)}."); + + try + { + await transformer.TransformAsync(operation, context, cancellationToken); + } + finally + { + if (transformer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (transformer is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiSchemaTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiSchemaTransformer.cs new file mode 100644 index 00000000000..5aaaf93941b --- /dev/null +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiSchemaTransformer.cs @@ -0,0 +1,43 @@ +// 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 System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class TypeBasedOpenApiSchemaTransformer : IOpenApiSchemaTransformer +{ + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + private readonly Type _transformerType; + private readonly ObjectFactory _transformerFactory; + + internal TypeBasedOpenApiSchemaTransformer([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type transformerType) + { + _transformerType = transformerType; + _transformerFactory = ActivatorUtilities.CreateFactory(_transformerType, []); + } + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiSchemaTransformer; + Debug.Assert(transformer != null, $"The type {_transformerType} does not implement {nameof(IOpenApiSchemaTransformer)}."); + try + { + await transformer.TransformAsync(schema, context, cancellationToken); + } + finally + { + if (transformer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (transformer is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt index e9d352c92a3..c5baf51f8bf 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt @@ -34,6 +34,11 @@ "tags": [ "Sample" ], + "externalDocs": { + "description": "Documentation for this OpenAPI endpoint", + "url": "https://example.com/api/docs/operations/CreateUser" + }, + "operationId": "CreateUser", "responses": { "200": { "description": "OK" @@ -48,6 +53,10 @@ "type": "array", "items": { "type": "string" + }, + "externalDocs": { + "description": "Documentation for this OpenAPI schema", + "url": "https://example.com/api/docs/schemas/array" } } } diff --git a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs index 2af401a19b0..d6e87811c57 100644 --- a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs @@ -18,12 +18,12 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseTransformer((document, context, cancellationToken) => + options.AddDocumentTransformer((document, context, cancellationToken) => { document.Info.Description = "1"; return Task.CompletedTask; }); - options.UseTransformer((document, context, cancellationToken) => + options.AddDocumentTransformer((document, context, cancellationToken) => { Assert.Equal("1", document.Info.Description); document.Info.Description = "2"; @@ -45,7 +45,7 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseTransformer(); + options.AddDocumentTransformer(); await VerifyOpenApiDocument(builder, options, document => { @@ -62,7 +62,7 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseTransformer(new ActivatedTransformer()); + options.AddDocumentTransformer(new ActivatedTransformer()); await VerifyOpenApiDocument(builder, options, document => { @@ -79,7 +79,7 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/todo", () => { }); var options = new OpenApiOptions(); - options.UseTransformer(); + options.AddDocumentTransformer(); // Assert that singleton dependency is only instantiated once // regardless of the number of requests. @@ -105,7 +105,7 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/todo", () => { }); var options = new OpenApiOptions(); - options.UseTransformer(); + options.AddDocumentTransformer(); // Assert that transient dependency is instantiated twice for each // request to the OpenAPI document. @@ -131,7 +131,7 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseTransformer(); + options.AddDocumentTransformer(); DisposableTransformer.DisposeCount = 0; await VerifyOpenApiDocument(builder, options, document => @@ -150,7 +150,7 @@ public class DocumentTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseTransformer(); + options.AddDocumentTransformer(); AsyncDisposableTransformer.DisposeCount = 0; await VerifyOpenApiDocument(builder, options, document => diff --git a/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs b/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs index c746e3e9760..7552c15a077 100644 --- a/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs +++ b/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs @@ -267,7 +267,7 @@ public class OpenApiSchemaReferenceTransformerTests : OpenApiDocumentServiceTest builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { if (context.Type == typeof(Todo) && context.ParameterDescription is not null) { diff --git a/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs index dba656f4cf3..283d39c3c04 100644 --- a/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs +++ b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs @@ -7,7 +7,7 @@ using Microsoft.OpenApi.Models; public class OpenApiOptionsTests { [Fact] - public void UseTransformer_WithDocumentTransformerDelegate() + public void AddDocumentTransformer_WithDocumentTransformerDelegate() { // Arrange var options = new OpenApiOptions(); @@ -18,47 +18,53 @@ public class OpenApiOptionsTests }); // Act - var result = options.UseTransformer(transformer); + var result = options.AddDocumentTransformer(transformer); // Assert var insertedTransformer = Assert.Single(options.DocumentTransformers); Assert.IsType(insertedTransformer); Assert.IsType(result); + Assert.Empty(options.OperationTransformers); + Assert.Empty(options.SchemaTransformers); } [Fact] - public void UseTransformer_WithDocumentTransformerInstance() + public void AddDocumentTransformer_WithDocumentTransformerInstance() { // Arrange var options = new OpenApiOptions(); var transformer = new TestOpenApiDocumentTransformer(); // Act - var result = options.UseTransformer(transformer); + var result = options.AddDocumentTransformer(transformer); // Assert var insertedTransformer = Assert.Single(options.DocumentTransformers); Assert.Same(transformer, insertedTransformer); Assert.IsType(result); + Assert.Empty(options.OperationTransformers); + Assert.Empty(options.SchemaTransformers); } [Fact] - public void UseTransformer_WithDocumentTransformerType() + public void AddDocumentTransformer_WithDocumentTransformerType() { // Arrange var options = new OpenApiOptions(); // Act - var result = options.UseTransformer(); + var result = options.AddDocumentTransformer(); // Assert var insertedTransformer = Assert.Single(options.DocumentTransformers); Assert.IsType(insertedTransformer); Assert.IsType(result); + Assert.Empty(options.OperationTransformers); + Assert.Empty(options.SchemaTransformers); } [Fact] - public void UseTransformer_WithOperationTransformerDelegate() + public void AddOperationTransformer_WithOperationTransformerDelegate() { // Arrange var options = new OpenApiOptions(); @@ -69,12 +75,106 @@ public class OpenApiOptionsTests }); // Act - var result = options.UseOperationTransformer(transformer); + var result = options.AddOperationTransformer(transformer); // Assert - var insertedTransformer = Assert.Single(options.DocumentTransformers); - Assert.IsType(insertedTransformer); + var insertedTransformer = Assert.Single(options.OperationTransformers); + Assert.IsType(insertedTransformer); Assert.IsType(result); + Assert.Empty(options.DocumentTransformers); + Assert.Empty(options.SchemaTransformers); + } + + [Fact] + public void AddOperationTransformer_WithOperationTransformerInstance() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new TestOpenApiOperationTransformer(); + + // Act + var result = options.AddOperationTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.OperationTransformers); + Assert.Same(transformer, insertedTransformer); + Assert.IsType(result); + Assert.Empty(options.DocumentTransformers); + Assert.Empty(options.SchemaTransformers); + } + + [Fact] + public void AddOperationTransformer_WithOperationTransformerType() + { + // Arrange + var options = new OpenApiOptions(); + + // Act + var result = options.AddOperationTransformer(); + + // Assert + var insertedTransformer = Assert.Single(options.OperationTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + Assert.Empty(options.DocumentTransformers); + Assert.Empty(options.SchemaTransformers); + } + + [Fact] + public void AddSchemaTransformer_WithSchemaTransformerDelegate() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new Func((schema, context, cancellationToken) => + { + schema.Description = "New Description"; + return Task.CompletedTask; + }); + + // Act + var result = options.AddSchemaTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.SchemaTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + Assert.Empty(options.DocumentTransformers); + Assert.Empty(options.OperationTransformers); + } + + [Fact] + public void AddSchemaTransformer_WithSchemaTransformerInstance() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new TestOpenApiSchemaTransformer(); + + // Act + var result = options.AddSchemaTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.SchemaTransformers); + Assert.Same(transformer, insertedTransformer); + Assert.IsType(result); + Assert.Empty(options.DocumentTransformers); + Assert.Empty(options.OperationTransformers); + } + + [Fact] + public void AddSchemaTransformer_WithSchemaTransformerType() + { + // Arrange + var options = new OpenApiOptions(); + + // Act + var result = options.AddSchemaTransformer(); + + // Assert + var insertedTransformer = Assert.Single(options.SchemaTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + Assert.Empty(options.DocumentTransformers); + Assert.Empty(options.OperationTransformers); } private class TestOpenApiDocumentTransformer : IOpenApiDocumentTransformer @@ -84,4 +184,20 @@ public class OpenApiOptionsTests return Task.CompletedTask; } } + + private class TestOpenApiOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + + private class TestOpenApiSchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } } diff --git a/src/OpenApi/test/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Transformers/OperationTransformerTests.cs index 877d2dfd52b..987abc5ca41 100644 --- a/src/OpenApi/test/Transformers/OperationTransformerTests.cs +++ b/src/OpenApi/test/Transformers/OperationTransformerTests.cs @@ -1,8 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; public class OperationTransformerTests : OpenApiDocumentServiceTestBase { @@ -15,7 +18,7 @@ public class OperationTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseOperationTransformer((operation, context, cancellationToken) => + options.AddOperationTransformer((operation, context, cancellationToken) => { var apiDescription = context.Description; operation.Description = apiDescription.RelativePath; @@ -41,7 +44,7 @@ public class OperationTransformerTests : OpenApiDocumentServiceTestBase } [Fact] - public async Task OperationTransformer_RunsInRegisteredOrder() + public async Task OperationTransformers_RunInRegisteredOrder() { var builder = CreateBuilder(); @@ -49,21 +52,40 @@ public class OperationTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseOperationTransformer((operation, context, cancellationToken) => + + // While added first, document transformers should run after the operation transformers + options.AddDocumentTransformer(); + options.AddDocumentTransformer((document, context, cancellationToken) => { + Assert.All(document.Paths.Values.SelectMany(p => p.Operations).Select(p => p.Value), o => Assert.Equal("6", o.Description)); + return Task.CompletedTask; + }); + + // Operation transforms should run FIFO regardless of which kind of transformer is used + options.AddOperationTransformer((operation, context, cancellationToken) => + { + Assert.Null(operation.Description); operation.Description = "1"; return Task.CompletedTask; }); - options.UseOperationTransformer((operation, context, cancellationToken) => + options.AddOperationTransformer((operation, context, cancellationToken) => { Assert.Equal("1", operation.Description); operation.Description = "2"; return Task.CompletedTask; }); - options.UseOperationTransformer((operation, context, cancellationToken) => + options.AddOperationTransformer(); + options.AddOperationTransformer(new MyOperationTransformer4()); + options.AddOperationTransformer((operation, context, cancellationToken) => { - Assert.Equal("2", operation.Description); - operation.Description = "3"; + Assert.Equal("4", operation.Description); + operation.Description = "5"; + return Task.CompletedTask; + }); + options.AddOperationTransformer((operation, context, cancellationToken) => + { + Assert.Equal("5", operation.Description); + operation.Description = "6"; return Task.CompletedTask; }); @@ -74,17 +96,46 @@ public class OperationTransformerTests : OpenApiDocumentServiceTestBase { Assert.Equal("/todo", path.Key); var operation = Assert.Single(path.Value.Operations.Values); - Assert.Equal("3", operation.Description); + Assert.Equal("6", operation.Description); }, path => { Assert.Equal("/user", path.Key); var operation = Assert.Single(path.Value.Operations.Values); - Assert.Equal("3", operation.Description); + Assert.Equal("6", operation.Description); }); }); } + private sealed class MyDocumentationTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + Assert.All(document.Paths.Values.SelectMany(p => p.Operations).Select(p => p.Value), o => Assert.Equal("6", o.Description)); + return Task.CompletedTask; + } + } + + private sealed class MyOperationTransformer3 : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Assert.Equal("2", operation.Description); + operation.Description = "3"; + return Task.CompletedTask; + } + } + + private sealed class MyOperationTransformer4 : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Assert.Equal("3", operation.Description); + operation.Description = "4"; + return Task.CompletedTask; + } + } + [Fact] public async Task OperationTransformer_CanMutateOperationViaDocumentTransformer() { @@ -94,7 +145,7 @@ public class OperationTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseTransformer((document, context, cancellationToken) => + options.AddDocumentTransformer((document, context, cancellationToken) => { foreach (var pathItem in document.Paths.Values) { @@ -125,24 +176,320 @@ public class OperationTransformerTests : OpenApiDocumentServiceTestBase } [Fact] - public async Task OperationTransformer_ThrowsExceptionIfDescriptionIdNotFound() + public async Task OperationTransformer_CanMutateOperationViaOperationTransformer() { var builder = CreateBuilder(); builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); var options = new OpenApiOptions(); - options.UseOperationTransformer((operation, context, cancellationToken) => - { - operation.Extensions.Remove("x-aspnetcore-id"); - return Task.CompletedTask; - }); - options.UseOperationTransformer((operation, context, cancellationToken) => + options.AddOperationTransformer((operation, context, cancellationToken) => { + operation.Description = "3"; return Task.CompletedTask; }); - var exception = await Assert.ThrowsAsync(() => VerifyOpenApiDocument(builder, options, _ => { })); - Assert.Equal("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute.", exception.Message); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_SupportsActivatedTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer(); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_SupportsInstanceTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer(new ActivatedTransformer()); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_SupportsActivatedTransformerWithSingletonDependency() + { + var serviceCollection = new ServiceCollection().AddSingleton(); + var builder = CreateBuilder(serviceCollection); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer(); + + // Assert that singleton dependency is only instantiated once + // regardless of the number of requests and operations. + string description = null; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + description = operation.Description; + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal(description, operation.Description); + }); + }); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal(description, operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal(description, operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_SupportsActivatedTransformerWithTransientDependency() + { + var serviceCollection = new ServiceCollection().AddTransient(); + var builder = CreateBuilder(serviceCollection); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer(); + + // Assert that transient dependency is instantiated once for each operation. + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("1", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("2", operation.Description); + }); + }); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("4", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_SupportsDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer(); + + DisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }); + }); + Assert.Equal(2, DisposableTransformer.DisposeCount); + } + + [Fact] + public async Task OperationTransformer_SupportsAsyncDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer(); + + AsyncDisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }); + }); + Assert.Equal(2, AsyncDisposableTransformer.DisposeCount); + } + + private class ActivatedTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + } + } + + private class DisposableTransformer : IOpenApiOperationTransformer, IDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public void Dispose() + { + Disposed = true; + DisposeCount += 1; + } + + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + } + } + + private class AsyncDisposableTransformer : IOpenApiOperationTransformer, IAsyncDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public ValueTask DisposeAsync() + { + Disposed = true; + DisposeCount += 1; + return ValueTask.CompletedTask; + } + + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + } + } + + private class ActivatedTransformerWithDependency(Dependency dependency) : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + dependency.TestMethod(); + operation.Description = Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + } + } + + private class Dependency + { + public Dependency() + { + InstantiationCount += 1; + } + + internal void TestMethod() { } + + internal static int InstantiationCount = 0; } } diff --git a/src/OpenApi/test/Transformers/SchemaTransformerTests.cs b/src/OpenApi/test/Transformers/SchemaTransformerTests.cs index 757fd3eb87f..ba9f8692743 100644 --- a/src/OpenApi/test/Transformers/SchemaTransformerTests.cs +++ b/src/OpenApi/test/Transformers/SchemaTransformerTests.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.Globalization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; @@ -17,7 +18,7 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase builder.MapPost("/todo", (Todo todo) => { }); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { Assert.Equal(typeof(Todo), context.Type); Assert.Equal("todo", context.ParameterDescription.Name); @@ -35,7 +36,7 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { Assert.Equal(typeof(Todo), context.Type); Assert.Null(context.ParameterDescription); @@ -53,7 +54,7 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { var service = context.ApplicationServices.GetKeyedService(context.DocumentName); Assert.NotNull(service); @@ -74,7 +75,7 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase cts.Cancel(); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { Assert.Equal(cts.Token, cancellationToken); Assert.True(cancellationToken.IsCancellationRequested); @@ -92,12 +93,12 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase builder.MapPost("/todo", (Todo todo) => { }); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { schema.Extensions["x-my-extension"] = new OpenApiString("1"); return Task.CompletedTask; }); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { Assert.Equal("1", ((OpenApiString)schema.Extensions["x-my-extension"]).Value); schema.Extensions["x-my-extension"] = new OpenApiString("2"); @@ -121,7 +122,7 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { if (context.Type == typeof(Todo)) { @@ -151,7 +152,7 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); var options = new OpenApiOptions(); - options.UseSchemaTransformer((schema, context, cancellationToken) => + options.AddSchemaTransformer((schema, context, cancellationToken) => { if (context.Type == typeof(Todo) && context.ParameterDescription is not null) { @@ -171,4 +172,248 @@ public class SchemaTransformerTests : OpenApiDocumentServiceTestBase Assert.False(responseSchema.Extensions.TryGetValue("x-my-extension", out var _)); }); } + + [Fact] + public async Task SchemaTransformer_SupportsActivatedTransformers() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer(); + + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.Equal("1", ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.Equal("1", ((OpenApiString)responseSchema.Extensions["x-my-extension"]).Value); + }); + } + + [Fact] + public async Task SchemaTransformer_SupportsInstanceTransformers() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer(new ActivatedTransformer()); + + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.Equal("1", ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.Equal("1", ((OpenApiString)responseSchema.Extensions["x-my-extension"]).Value); + }); + } + + [Fact] + public async Task SchemaTransformer_SupportsActivatedTransformerWithSingletonDependency() + { + var serviceCollection = new ServiceCollection().AddSingleton(); + var builder = CreateBuilder(serviceCollection); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer(); + + // Assert that singleton dependency is only instantiated once + // regardless of the number of requests, operations or schemas. + string value = null; + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + value = ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value; + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), value); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.Equal(value, ((OpenApiString)responseSchema.Extensions["x-my-extension"]).Value); + }); + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.Equal(value, ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.Equal(value, ((OpenApiString)responseSchema.Extensions["x-my-extension"]).Value); + }); + } + + [Fact] + public async Task SchemaTransformer_SupportsActivatedTransformerWithTransientDependency() + { + var serviceCollection = new ServiceCollection().AddTransient(); + var builder = CreateBuilder(serviceCollection); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer(); + + // Assert that transient dependency is instantiated once for each + // request to the OpenAPI document for each created schema. + var countBefore = Dependency.InstantiationCount; + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.True(requestSchema.Extensions.ContainsKey("x-my-extension")); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.True(responseSchema.Extensions.ContainsKey("x-my-extension")); + }); + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.True(requestSchema.Extensions.ContainsKey("x-my-extension")); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.True(responseSchema.Extensions.ContainsKey("x-my-extension")); + }); + var countAfter = Dependency.InstantiationCount; + Assert.Equal(countBefore + 4, countAfter); + } + + [Fact] + public async Task SchemaTransformer_SupportsDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer(); + + DisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.Equal("Schema Description", requestSchema.Description); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.Equal("Schema Description", responseSchema.Description); + }); + Assert.Equal(2, DisposableTransformer.DisposeCount); + } + + [Fact] + public async Task SchemaTransformer_SupportsAsyncDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.AddSchemaTransformer(); + + AsyncDisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); + Assert.Equal("Schema Description", requestSchema.Description); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); + Assert.Equal("Schema Description", responseSchema.Description); + }); + Assert.Equal(2, AsyncDisposableTransformer.DisposeCount); + } + + private class ActivatedTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.Type == typeof(Todo)) + { + schema.Extensions["x-my-extension"] = new OpenApiString("1"); + } + return Task.CompletedTask; + } + } + + private class DisposableTransformer : IOpenApiSchemaTransformer, IDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public void Dispose() + { + Disposed = true; + DisposeCount += 1; + } + + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + schema.Description = "Schema Description"; + return Task.CompletedTask; + } + } + + private class AsyncDisposableTransformer : IOpenApiSchemaTransformer, IAsyncDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public ValueTask DisposeAsync() + { + Disposed = true; + DisposeCount += 1; + return ValueTask.CompletedTask; + } + + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + schema.Description = "Schema Description"; + return Task.CompletedTask; + } + } + + private class ActivatedTransformerWithDependency(Dependency dependency) : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + dependency.TestMethod(); + schema.Extensions["x-my-extension"] = new OpenApiString(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture)); + return Task.CompletedTask; + } + } + + private class Dependency + { + public Dependency() + { + InstantiationCount += 1; + } + + internal void TestMethod() { } + + internal static int InstantiationCount = 0; + } }