Skip to content

Commit 7aa28a7

Browse files
authored
Add support for AddOpenApiOperationTransformer API (#60566)
* Add support for AddOpenApiOperationTransformer API * Apply all transformers in order instead of last-one-wins * Add cancellation and async tests to transformers * Fix Timespan declaration * Avoid delays in async and cancellation tests * Avoid delays in async and cancellation tests
1 parent b8f5b5e commit 7aa28a7

File tree

8 files changed

+378
-7
lines changed

8 files changed

+378
-7
lines changed

src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs

+13
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,17 @@ private static void AddAndConfigureOperationForEndpoint(EndpointBuilder endpoint
121121
}
122122
}
123123
}
124+
125+
/// <summary>
126+
/// Adds an OpenAPI operation transformer to the <see cref="EndpointBuilder.Metadata" /> associated
127+
/// with the current endpoint.
128+
/// </summary>
129+
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
130+
/// <param name="transformer">The <see cref="Func{OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task}"/> that modifies the operation in the <see cref="OpenApiDocument"/>.</param>
131+
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns>
132+
public static TBuilder AddOpenApiOperationTransformer<TBuilder>(this TBuilder builder, Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task> transformer) where TBuilder : IEndpointConventionBuilder
133+
{
134+
builder.WithMetadata(new DelegateOpenApiOperationTransformer(transformer));
135+
return builder;
136+
}
124137
}
+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.AspNetCore.Builder.OpenApiEndpointConventionBuilderExtensions.AddOpenApiOperationTransformer<TBuilder>(this TBuilder builder, System.Func<Microsoft.OpenApi.Models.OpenApiOperation!, Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! transformer) -> TBuilder

src/OpenApi/src/Services/OpenApiDocumentService.cs

+9
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsy
268268
var transformer = operationTransformers[i];
269269
await transformer.TransformAsync(operation, operationContext, cancellationToken);
270270
}
271+
272+
// Apply any endpoint-specific operation transformers registered via
273+
// the AddOpenApiOperationTransformer extension method.
274+
var endpointOperationTransformers = description.ActionDescriptor.EndpointMetadata
275+
.OfType<DelegateOpenApiOperationTransformer>();
276+
foreach (var endpointOperationTransformer in endpointOperationTransformers)
277+
{
278+
await endpointOperationTransformer.TransformAsync(operation, operationContext, cancellationToken);
279+
}
271280
}
272281
return operations;
273282
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.OpenApi.Build.Tests;
99

1010
public class GenerateAdditionalXmlFilesForOpenApiTests
1111
{
12-
private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromSeconds(120);
12+
private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromMinutes(2);
1313

1414
[Fact]
1515
public void VerifiesTargetGeneratesXmlFiles()

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs

+4-6
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33

44
using System.Reflection;
55
using System.Text;
6-
using System.Text.Json;
76
using Microsoft.AspNetCore.Builder;
87
using Microsoft.AspNetCore.Hosting.Server;
98
using Microsoft.AspNetCore.Hosting.Server.Features;
109
using Microsoft.AspNetCore.Http.Features;
11-
using Microsoft.AspNetCore.Http.Json;
1210
using Microsoft.AspNetCore.Mvc;
1311
using Microsoft.AspNetCore.Mvc.Abstractions;
1412
using Microsoft.AspNetCore.Mvc.ActionConstraints;
@@ -29,8 +27,8 @@
2927

3028
public abstract class OpenApiDocumentServiceTestBase
3129
{
32-
public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action<OpenApiDocument> verifyOpenApiDocument, CancellationToken cancellationToken = default)
33-
=> await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument, cancellationToken);
30+
public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action<OpenApiDocument> verifyOpenApiDocument)
31+
=> await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument);
3432

3533
public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions, Action<OpenApiDocument> verifyOpenApiDocument, CancellationToken cancellationToken = default)
3634
{
@@ -40,12 +38,12 @@ public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Op
4038
verifyOpenApiDocument(document);
4139
}
4240

43-
public static async Task VerifyOpenApiDocument(ActionDescriptor action, Action<OpenApiDocument> verifyOpenApiDocument)
41+
public static async Task VerifyOpenApiDocument(ActionDescriptor action, Action<OpenApiDocument> verifyOpenApiDocument, CancellationToken cancellationToken = default)
4442
{
4543
var builder = CreateBuilder();
4644
var documentService = CreateDocumentService(builder, action);
4745
var scopedService = ((TestServiceProvider)builder.ServiceProvider).CreateScope();
48-
var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider);
46+
var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider, cancellationToken);
4947
verifyOpenApiDocument(document);
5048
}
5149

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs

+83
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,89 @@ public async Task DocumentTransformer_CanAccessTransientServiceFromContextApplic
240240
Assert.Equal(2, Dependency.InstantiationCount);
241241
}
242242

243+
[Fact]
244+
public async Task DocumentTransformer_RespectsOperationCancellation()
245+
{
246+
var builder = CreateBuilder();
247+
builder.MapGet("/todo", () => { });
248+
249+
var options = new OpenApiOptions();
250+
var transformerCalled = false;
251+
var exceptionThrown = false;
252+
var tcs = new TaskCompletionSource();
253+
254+
options.AddDocumentTransformer(async (document, context, cancellationToken) =>
255+
{
256+
transformerCalled = true;
257+
try
258+
{
259+
await tcs.Task.WaitAsync(cancellationToken);
260+
document.Info.Description = "Should not be set";
261+
}
262+
catch (OperationCanceledException)
263+
{
264+
exceptionThrown = true;
265+
throw;
266+
}
267+
});
268+
269+
using var cts = new CancellationTokenSource();
270+
cts.CancelAfter(1);
271+
272+
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
273+
{
274+
await VerifyOpenApiDocument(builder, options, _ => { }, cts.Token);
275+
});
276+
277+
Assert.True(transformerCalled);
278+
Assert.True(exceptionThrown);
279+
}
280+
281+
[Fact]
282+
public async Task DocumentTransformer_ExecutesAsynchronously()
283+
{
284+
var builder = CreateBuilder();
285+
builder.MapGet("/todo", () => { });
286+
287+
var options = new OpenApiOptions();
288+
var transformerOrder = new List<int>();
289+
var tcs1 = new TaskCompletionSource();
290+
var tcs2 = new TaskCompletionSource();
291+
292+
options.AddDocumentTransformer(async (document, context, cancellationToken) =>
293+
{
294+
await tcs1.Task;
295+
transformerOrder.Add(1);
296+
document.Info.Title = "First";
297+
});
298+
299+
options.AddDocumentTransformer((document, context, cancellationToken) =>
300+
{
301+
transformerOrder.Add(2);
302+
document.Info.Title += " Second";
303+
tcs2.TrySetResult();
304+
return Task.CompletedTask;
305+
});
306+
307+
options.AddDocumentTransformer(async (document, context, cancellationToken) =>
308+
{
309+
await tcs2.Task;
310+
transformerOrder.Add(3);
311+
document.Info.Title += " Third";
312+
});
313+
314+
var documentTask = VerifyOpenApiDocument(builder, options, document =>
315+
{
316+
Assert.Equal("First Second Third", document.Info.Title);
317+
});
318+
319+
tcs1.TrySetResult();
320+
321+
await documentTask;
322+
323+
Assert.Equal([1, 2, 3], transformerOrder);
324+
}
325+
243326
private class ActivatedTransformer : IOpenApiDocumentTransformer
244327
{
245328
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs

+179
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,185 @@ public async Task OperationTransformer_CanAccessTransientServiceFromContextAppli
478478
Assert.Equal(4, Dependency.InstantiationCount);
479479
}
480480

481+
[Fact]
482+
public async Task AddOpenApiOperationTransformer_CanApplyTransformer()
483+
{
484+
var builder = CreateBuilder();
485+
486+
builder.MapGet("/", () => { })
487+
.AddOpenApiOperationTransformer((operation, context, cancellationToken) =>
488+
{
489+
operation.Description = "Operation Description";
490+
return Task.CompletedTask;
491+
});
492+
493+
await VerifyOpenApiDocument(builder, document =>
494+
{
495+
Assert.Collection(document.Paths.OrderBy(p => p.Key),
496+
path =>
497+
{
498+
Assert.Equal("/", path.Key);
499+
var operation = Assert.Single(path.Value.Operations.Values);
500+
Assert.Equal("Operation Description", operation.Description);
501+
});
502+
});
503+
}
504+
505+
[Fact]
506+
public async Task AddOpenApiOperationTransformer_TransformerRunsAfterOtherTransformers()
507+
{
508+
var builder = CreateBuilder();
509+
510+
builder.MapGet("/", () => { })
511+
.AddOpenApiOperationTransformer((operation, context, cancellationToken) =>
512+
{
513+
operation.Description = "Operation Description";
514+
return Task.CompletedTask;
515+
});
516+
517+
var options = new OpenApiOptions();
518+
options.AddOperationTransformer((operation, context, cancellationToken) =>
519+
{
520+
operation.Description = "Operation Description 2";
521+
return Task.CompletedTask;
522+
});
523+
524+
await VerifyOpenApiDocument(builder, document =>
525+
{
526+
Assert.Collection(document.Paths.OrderBy(p => p.Key),
527+
path =>
528+
{
529+
Assert.Equal("/", path.Key);
530+
var operation = Assert.Single(path.Value.Operations.Values);
531+
Assert.Equal("Operation Description", operation.Description);
532+
});
533+
});
534+
}
535+
536+
[Fact]
537+
public async Task AddOpenApiOperationTransformer_SupportsMultipleTransformers()
538+
{
539+
var builder = CreateBuilder();
540+
541+
builder.MapGet("/", () => { })
542+
.AddOpenApiOperationTransformer((operation, context, cancellationToken) =>
543+
{
544+
operation.Description = "Operation Description";
545+
return Task.CompletedTask;
546+
})
547+
.AddOpenApiOperationTransformer((operation, context, cancellationToken) =>
548+
{
549+
operation.Description += " 2";
550+
operation.Deprecated = true;
551+
return Task.CompletedTask;
552+
})
553+
.AddOpenApiOperationTransformer((operation, context, cancellationToken) =>
554+
{
555+
operation.Description += " 3";
556+
operation.OperationId = "OperationId";
557+
return Task.CompletedTask;
558+
});
559+
560+
await VerifyOpenApiDocument(builder, document =>
561+
{
562+
Assert.Collection(document.Paths.OrderBy(p => p.Key),
563+
path =>
564+
{
565+
Assert.Equal("/", path.Key);
566+
var operation = Assert.Single(path.Value.Operations.Values);
567+
Assert.Equal("Operation Description 2 3", operation.Description);
568+
Assert.True(operation.Deprecated);
569+
Assert.Equal("OperationId", operation.OperationId);
570+
});
571+
});
572+
}
573+
574+
[Fact]
575+
public async Task OperationTransformer_RespectsOperationCancellation()
576+
{
577+
var builder = CreateBuilder();
578+
builder.MapGet("/todo", () => { });
579+
580+
var options = new OpenApiOptions();
581+
var transformerCalled = false;
582+
var exceptionThrown = false;
583+
var tcs = new TaskCompletionSource();
584+
585+
options.AddOperationTransformer(async (operation, context, cancellationToken) =>
586+
{
587+
transformerCalled = true;
588+
try
589+
{
590+
await tcs.Task.WaitAsync(cancellationToken);
591+
operation.Description = "Should not be set";
592+
}
593+
catch (OperationCanceledException)
594+
{
595+
exceptionThrown = true;
596+
throw;
597+
}
598+
});
599+
600+
using var cts = new CancellationTokenSource();
601+
cts.CancelAfter(1);
602+
603+
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
604+
{
605+
await VerifyOpenApiDocument(builder, options, _ => { }, cts.Token);
606+
});
607+
608+
Assert.True(transformerCalled);
609+
Assert.True(exceptionThrown);
610+
}
611+
612+
[Fact]
613+
public async Task OperationTransformer_ExecutesAsynchronously()
614+
{
615+
var builder = CreateBuilder();
616+
builder.MapGet("/todo", () => { });
617+
618+
var options = new OpenApiOptions();
619+
var transformerOrder = new List<int>();
620+
var tcs1 = new TaskCompletionSource();
621+
var tcs2 = new TaskCompletionSource();
622+
623+
options.AddOperationTransformer(async (operation, context, cancellationToken) =>
624+
{
625+
await tcs1.Task;
626+
transformerOrder.Add(1);
627+
operation.Description = "First";
628+
});
629+
630+
options.AddOperationTransformer((operation, context, cancellationToken) =>
631+
{
632+
transformerOrder.Add(2);
633+
operation.Description += " Second";
634+
tcs2.TrySetResult();
635+
return Task.CompletedTask;
636+
});
637+
638+
options.AddOperationTransformer(async (operation, context, cancellationToken) =>
639+
{
640+
await tcs2.Task;
641+
transformerOrder.Add(3);
642+
operation.Description += " Third";
643+
});
644+
645+
var documentTask = VerifyOpenApiDocument(builder, options, document =>
646+
{
647+
var operation = Assert.Single(document.Paths["/todo"].Operations.Values);
648+
Assert.Equal("First Second Third", operation.Description);
649+
});
650+
651+
tcs1.TrySetResult();
652+
653+
await documentTask;
654+
655+
// Verify transformers executed in the correct order, once for each transformer
656+
// since there is a single operation in the document.
657+
Assert.Equal([1, 2, 3], transformerOrder);
658+
}
659+
481660
private class ActivatedTransformer : IOpenApiOperationTransformer
482661
{
483662
public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)

0 commit comments

Comments
 (0)