diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs index 3e325362f6bf..936eebf24cd4 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs @@ -121,4 +121,17 @@ private static void AddAndConfigureOperationForEndpoint(EndpointBuilder endpoint } } } + + /// + /// Adds an OpenAPI operation transformer to the associated + /// with the current endpoint. + /// + /// The . + /// The that modifies the operation in the . + /// A that can be used to further customize the endpoint. + public static TBuilder AddOpenApiOperationTransformer(this TBuilder builder, Func transformer) where TBuilder : IEndpointConventionBuilder + { + builder.WithMetadata(new DelegateOpenApiOperationTransformer(transformer)); + return builder; + } } diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..e84200ba1e60 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +static Microsoft.AspNetCore.Builder.OpenApiEndpointConventionBuilderExtensions.AddOpenApiOperationTransformer(this TBuilder builder, System.Func! transformer) -> TBuilder diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 45fa4fa3f9f0..73ebd9541cb9 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -268,6 +268,15 @@ private async Task> GetOperationsAsy var transformer = operationTransformers[i]; await transformer.TransformAsync(operation, operationContext, cancellationToken); } + + // Apply any endpoint-specific operation transformers registered via + // the AddOpenApiOperationTransformer extension method. + var endpointOperationTransformers = description.ActionDescriptor.EndpointMetadata + .OfType(); + foreach (var endpointOperationTransformer in endpointOperationTransformers) + { + await endpointOperationTransformer.TransformAsync(operation, operationContext, cancellationToken); + } } return operations; } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs index 9f066e5cab77..f230d7186e5d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.OpenApi.Build.Tests; public class GenerateAdditionalXmlFilesForOpenApiTests { - private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromSeconds(120); + private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromMinutes(2); [Fact] public void VerifiesTargetGeneratesXmlFiles() diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs index 00aeaef2e6d5..857325fa0cd3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs @@ -3,12 +3,10 @@ using System.Reflection; using System.Text; -using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; @@ -29,8 +27,8 @@ public abstract class OpenApiDocumentServiceTestBase { - public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action verifyOpenApiDocument, CancellationToken cancellationToken = default) - => await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument, cancellationToken); + public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action verifyOpenApiDocument) + => await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument); public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions, Action verifyOpenApiDocument, CancellationToken cancellationToken = default) { @@ -40,12 +38,12 @@ public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Op verifyOpenApiDocument(document); } - public static async Task VerifyOpenApiDocument(ActionDescriptor action, Action verifyOpenApiDocument) + public static async Task VerifyOpenApiDocument(ActionDescriptor action, Action verifyOpenApiDocument, CancellationToken cancellationToken = default) { var builder = CreateBuilder(); var documentService = CreateDocumentService(builder, action); var scopedService = ((TestServiceProvider)builder.ServiceProvider).CreateScope(); - var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider); + var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider, cancellationToken); verifyOpenApiDocument(document); } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs index 30bc6f82f7ca..27741a0afc4d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs @@ -240,6 +240,89 @@ public async Task DocumentTransformer_CanAccessTransientServiceFromContextApplic Assert.Equal(2, Dependency.InstantiationCount); } + [Fact] + public async Task DocumentTransformer_RespectsOperationCancellation() + { + var builder = CreateBuilder(); + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + var transformerCalled = false; + var exceptionThrown = false; + var tcs = new TaskCompletionSource(); + + options.AddDocumentTransformer(async (document, context, cancellationToken) => + { + transformerCalled = true; + try + { + await tcs.Task.WaitAsync(cancellationToken); + document.Info.Description = "Should not be set"; + } + catch (OperationCanceledException) + { + exceptionThrown = true; + throw; + } + }); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(1); + + await Assert.ThrowsAsync(async () => + { + await VerifyOpenApiDocument(builder, options, _ => { }, cts.Token); + }); + + Assert.True(transformerCalled); + Assert.True(exceptionThrown); + } + + [Fact] + public async Task DocumentTransformer_ExecutesAsynchronously() + { + var builder = CreateBuilder(); + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + var transformerOrder = new List(); + var tcs1 = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + + options.AddDocumentTransformer(async (document, context, cancellationToken) => + { + await tcs1.Task; + transformerOrder.Add(1); + document.Info.Title = "First"; + }); + + options.AddDocumentTransformer((document, context, cancellationToken) => + { + transformerOrder.Add(2); + document.Info.Title += " Second"; + tcs2.TrySetResult(); + return Task.CompletedTask; + }); + + options.AddDocumentTransformer(async (document, context, cancellationToken) => + { + await tcs2.Task; + transformerOrder.Add(3); + document.Info.Title += " Third"; + }); + + var documentTask = VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("First Second Third", document.Info.Title); + }); + + tcs1.TrySetResult(); + + await documentTask; + + Assert.Equal([1, 2, 3], transformerOrder); + } + private class ActivatedTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs index c85c2b2ec8e8..f172ac364f55 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs @@ -478,6 +478,185 @@ public async Task OperationTransformer_CanAccessTransientServiceFromContextAppli Assert.Equal(4, Dependency.InstantiationCount); } + [Fact] + public async Task AddOpenApiOperationTransformer_CanApplyTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/", () => { }) + .AddOpenApiOperationTransformer((operation, context, cancellationToken) => + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }); + }); + } + + [Fact] + public async Task AddOpenApiOperationTransformer_TransformerRunsAfterOtherTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/", () => { }) + .AddOpenApiOperationTransformer((operation, context, cancellationToken) => + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + }); + + var options = new OpenApiOptions(); + options.AddOperationTransformer((operation, context, cancellationToken) => + { + operation.Description = "Operation Description 2"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description", operation.Description); + }); + }); + } + + [Fact] + public async Task AddOpenApiOperationTransformer_SupportsMultipleTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/", () => { }) + .AddOpenApiOperationTransformer((operation, context, cancellationToken) => + { + operation.Description = "Operation Description"; + return Task.CompletedTask; + }) + .AddOpenApiOperationTransformer((operation, context, cancellationToken) => + { + operation.Description += " 2"; + operation.Deprecated = true; + return Task.CompletedTask; + }) + .AddOpenApiOperationTransformer((operation, context, cancellationToken) => + { + operation.Description += " 3"; + operation.OperationId = "OperationId"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("Operation Description 2 3", operation.Description); + Assert.True(operation.Deprecated); + Assert.Equal("OperationId", operation.OperationId); + }); + }); + } + + [Fact] + public async Task OperationTransformer_RespectsOperationCancellation() + { + var builder = CreateBuilder(); + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + var transformerCalled = false; + var exceptionThrown = false; + var tcs = new TaskCompletionSource(); + + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + transformerCalled = true; + try + { + await tcs.Task.WaitAsync(cancellationToken); + operation.Description = "Should not be set"; + } + catch (OperationCanceledException) + { + exceptionThrown = true; + throw; + } + }); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(1); + + await Assert.ThrowsAsync(async () => + { + await VerifyOpenApiDocument(builder, options, _ => { }, cts.Token); + }); + + Assert.True(transformerCalled); + Assert.True(exceptionThrown); + } + + [Fact] + public async Task OperationTransformer_ExecutesAsynchronously() + { + var builder = CreateBuilder(); + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + var transformerOrder = new List(); + var tcs1 = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + await tcs1.Task; + transformerOrder.Add(1); + operation.Description = "First"; + }); + + options.AddOperationTransformer((operation, context, cancellationToken) => + { + transformerOrder.Add(2); + operation.Description += " Second"; + tcs2.TrySetResult(); + return Task.CompletedTask; + }); + + options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + await tcs2.Task; + transformerOrder.Add(3); + operation.Description += " Third"; + }); + + var documentTask = VerifyOpenApiDocument(builder, options, document => + { + var operation = Assert.Single(document.Paths["/todo"].Operations.Values); + Assert.Equal("First Second Third", operation.Description); + }); + + tcs1.TrySetResult(); + + await documentTask; + + // Verify transformers executed in the correct order, once for each transformer + // since there is a single operation in the document. + Assert.Equal([1, 2, 3], transformerOrder); + } + private class ActivatedTransformer : IOpenApiOperationTransformer { public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/SchemaTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/SchemaTransformerTests.cs index 699512213905..9cec9411254e 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/SchemaTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/SchemaTransformerTests.cs @@ -848,6 +848,94 @@ public async Task SchemaTransformer_CanAccessTransientServiceFromContextApplicat Assert.Equal(10, Dependency.InstantiationCount); } + [Fact] + public async Task SchemaTransformer_RespectsOperationCancellation() + { + var builder = CreateBuilder(); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + var transformerCalled = false; + var exceptionThrown = false; + var tcs = new TaskCompletionSource(); + + //Assert that transformers wait for completion signal from sibling tasks before running + options.AddSchemaTransformer(async (schema, context, cancellationToken) => + { + transformerCalled = true; + try + { + await tcs.Task.WaitAsync(cancellationToken); + schema.Description = "Should not be set"; + } + catch (OperationCanceledException) + { + exceptionThrown = true; + throw; + } + }); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(1); + + await Assert.ThrowsAsync(async () => + { + await VerifyOpenApiDocument(builder, options, _ => { }, cts.Token); + }); + + Assert.True(transformerCalled); + Assert.True(exceptionThrown); + } + + [Fact] + public async Task SchemaTransformer_ExecutesAsynchronously() + { + var builder = CreateBuilder(); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + var transformerOrder = new List(); + var tcs1 = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + + options.AddSchemaTransformer(async (schema, context, cancellationToken) => + { + await tcs1.Task; + transformerOrder.Add(1); + schema.Description = "First"; + }); + + options.AddSchemaTransformer((schema, context, cancellationToken) => + { + transformerOrder.Add(2); + schema.Description += " Second"; + tcs2.TrySetResult(); + return Task.CompletedTask; + }); + + options.AddSchemaTransformer(async (schema, context, cancellationToken) => + { + await tcs2.Task; + transformerOrder.Add(3); + schema.Description += " Third"; + }); + + var documentTask = VerifyOpenApiDocument(builder, options, document => + { + var operation = Assert.Single(document.Paths["/todo"].Operations.Values); + var schema = operation.Responses["200"].Content["application/json"].Schema; + Assert.Equal("First Second Third", schema.Description); + }); + + tcs1.TrySetResult(); + + await documentTask; + + // Each transformer is called a total of 5 times, once for the top-level schema + // and one for each of the four properties within the Todo type. + Assert.Equal([1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3], transformerOrder); + } + private class PolymorphicContainer { public string Name { get; }