Skip to content

Commit 5722bc5

Browse files
committed
refactor to better support operation transformers
1 parent 656fe93 commit 5722bc5

File tree

5 files changed

+106
-33
lines changed

5 files changed

+106
-33
lines changed

src/OpenApi/sample/Controllers/XmlController.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88
[ApiExplorerSettings(GroupName = "xml")]
99
public class XmlController : ControllerBase
1010
{
11+
/// <param name="name">The name of the person.</param>
12+
/// <response code="200">Returns the greeting.</response>
13+
[HttpGet]
14+
public string Get1(string name)
15+
{
16+
return $"Hello, {name}!";
17+
}
18+
1119
/// <summary>
1220
/// A summary of the action.
1321
/// </summary>
@@ -20,14 +28,6 @@ public string Get()
2028
return "Hello, World!";
2129
}
2230

23-
/// <param name="name">The name of the person.</param>
24-
/// <response code="200">Returns the greeting.</response>
25-
[HttpGet]
26-
public string Get1(string name)
27-
{
28-
return $"Hello, {name}!";
29-
}
30-
3131
/// <param name="todo">The todo to insert into the database.</param>
3232
[HttpPost]
3333
public string Post(Todo todo)

src/OpenApi/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ static Microsoft.AspNetCore.Builder.OpenApiEndpointConventionBuilderExtensions.A
33
Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.OpenApi.Models.OpenApiSchema!>!
44
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument?
55
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.init -> void
6+
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.AllDescriptions.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription!>!
7+
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.AllDescriptions.init -> void
68
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.OpenApi.Models.OpenApiSchema!>!
79
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument?
810
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.init -> void

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -262,22 +262,33 @@ private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsy
262262
CancellationToken cancellationToken)
263263
{
264264
var operations = new Dictionary<OperationType, OpenApiOperation>();
265-
foreach (var description in descriptions)
265+
foreach (var opTypeDescriptions in descriptions.GroupBy(d => d.GetOperationType()))
266266
{
267-
var operation = await GetOperationAsync(description, document, scopedServiceProvider, schemaTransformers, cancellationToken);
267+
var operationType = opTypeDescriptions.Key;
268+
269+
// `description` is the first description for a given Route + HttpMethod.
270+
// There may be additional descriptions if the endpoint has additional definitions
271+
// with different [Consumes] definitions. We merge in the bodies of these additional endpoints,
272+
// but currently don't merge any other parts of the definition.
273+
IReadOnlyList<ApiDescription> allDescriptions = [.. opTypeDescriptions];
274+
var description = allDescriptions.First();
275+
276+
var operation = await GetOperationAsync(allDescriptions, document, scopedServiceProvider, schemaTransformers, cancellationToken);
268277
operation.Annotations ??= new Dictionary<string, object>();
269278
operation.Annotations.Add(OpenApiConstants.DescriptionId, description.ActionDescriptor.Id);
270279

271280
var operationContext = new OpenApiOperationTransformerContext
272281
{
273282
DocumentName = documentName,
274283
Description = description,
284+
AllDescriptions = [.. allDescriptions],
275285
ApplicationServices = scopedServiceProvider,
276286
Document = document,
277287
SchemaTransformers = schemaTransformers
278288
};
279289

280290
_operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, operationContext);
291+
operations[operationType] = operation;
281292

282293
// Use index-based for loop to avoid allocating an enumerator with a foreach.
283294
for (var i = 0; i < operationTransformers.Length; i++)
@@ -288,41 +299,25 @@ private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsy
288299

289300
// Apply any endpoint-specific operation transformers registered via
290301
// the AddOpenApiOperationTransformer extension method.
291-
var endpointOperationTransformers = description.ActionDescriptor.EndpointMetadata
292-
.OfType<DelegateOpenApiOperationTransformer>();
302+
var endpointOperationTransformers = allDescriptions
303+
.SelectMany(d => d.ActionDescriptor.EndpointMetadata
304+
.OfType<DelegateOpenApiOperationTransformer>());
293305
foreach (var endpointOperationTransformer in endpointOperationTransformers)
294306
{
295307
await endpointOperationTransformer.TransformAsync(operation, operationContext, cancellationToken);
296308
}
297-
298-
var operationType = description.GetOperationType();
299-
if (
300-
operations.TryGetValue(operationType, out var existingOperation) &&
301-
existingOperation.RequestBody?.Content is not null &&
302-
operation.RequestBody is { Content.Count: > 0 }
303-
)
304-
{
305-
// Merge additional accepted content types into the existing operation.
306-
foreach (var body in operation.RequestBody.Content)
307-
{
308-
existingOperation.RequestBody.Content.Add(body);
309-
}
310-
}
311-
else
312-
{
313-
operations[operationType] = operation;
314-
}
315309
}
316310
return operations;
317311
}
318312

319313
private async Task<OpenApiOperation> GetOperationAsync(
320-
ApiDescription description,
314+
IReadOnlyList<ApiDescription> descriptions,
321315
OpenApiDocument document,
322316
IServiceProvider scopedServiceProvider,
323317
IOpenApiSchemaTransformer[] schemaTransformers,
324318
CancellationToken cancellationToken)
325319
{
320+
var description = descriptions.First();
326321
var tags = GetTags(description, document);
327322
var operation = new OpenApiOperation
328323
{
@@ -331,9 +326,30 @@ private async Task<OpenApiOperation> GetOperationAsync(
331326
Description = GetDescription(description),
332327
Responses = await GetResponsesAsync(document, description, scopedServiceProvider, schemaTransformers, cancellationToken),
333328
Parameters = await GetParametersAsync(document, description, scopedServiceProvider, schemaTransformers, cancellationToken),
334-
RequestBody = await GetRequestBodyAsync(document, description, scopedServiceProvider, schemaTransformers, cancellationToken),
335329
Tags = tags,
336330
};
331+
332+
foreach (var bodyDescription in descriptions)
333+
{
334+
var requestBody = await GetRequestBodyAsync(document, bodyDescription, scopedServiceProvider, schemaTransformers, cancellationToken);
335+
if (operation.RequestBody is null)
336+
{
337+
operation.RequestBody = requestBody;
338+
}
339+
else if (requestBody is not null)
340+
{
341+
// Merge additional accepted content types that are defined by additional endpoint descriptions.
342+
var existingContent = operation.RequestBody.Content;
343+
foreach (var additionalContent in requestBody.Content)
344+
{
345+
if (!existingContent.ContainsKey(additionalContent.Key))
346+
{
347+
existingContent.Add(additionalContent);
348+
}
349+
}
350+
}
351+
}
352+
337353
return operation;
338354
}
339355

src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ public sealed class OpenApiOperationTransformerContext
1818
public required string DocumentName { get; init; }
1919

2020
/// <summary>
21-
/// Gets the API description associated with target operation.
21+
/// Gets the primary API description associated with target operation.
2222
/// </summary>
2323
public required ApiDescription Description { get; init; }
2424

25+
/// <summary>
26+
/// Gets all API descriptions that were merged to create the target operation.
27+
/// </summary>
28+
public required IReadOnlyList<ApiDescription> AllDescriptions { get; init; }
29+
2530
/// <summary>
2631
/// Gets the application services associated with the current document the target operation is in.
2732
/// </summary>

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Globalization;
55
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.Mvc;
67
using Microsoft.AspNetCore.OpenApi;
78
using Microsoft.Extensions.DependencyInjection;
89
using Microsoft.OpenApi.Models;
@@ -657,6 +658,55 @@ public async Task OperationTransformer_ExecutesAsynchronously()
657658
Assert.Equal([1, 2, 3], transformerOrder);
658659
}
659660

661+
[Fact]
662+
public async Task OperationTransformer_ExecutesOncePerSetOfMergedEndpoints()
663+
{
664+
// Arrange
665+
var builder = CreateBuilder();
666+
667+
// Act
668+
builder.MapPost("/overload", [Consumes("application/json")] (TodoWithDueDate name) => { });
669+
builder.MapPost("/overload", [Consumes("application/x-www-form-urlencoded")] ([FromForm] TodoWithDueDate name) => { });
670+
671+
var options = new OpenApiOptions();
672+
int executionCount = 0;
673+
options.AddOperationTransformer((operation, context, cancellationToken) =>
674+
{
675+
executionCount++;
676+
677+
Assert.Collection(context.AllDescriptions,
678+
description =>
679+
{
680+
Assert.Equal("application/json", description.SupportedRequestFormats.Single().MediaType);
681+
// The primary description (the first declared endpoint) should be first in AllDescriptions.
682+
Assert.Equal(context.Description, description);
683+
},
684+
description =>
685+
{
686+
Assert.Equal("application/x-www-form-urlencoded", description.SupportedRequestFormats.Single().MediaType);
687+
});
688+
689+
operation.Description = "overloaded x" + context.AllDescriptions.Count;
690+
return Task.CompletedTask;
691+
});
692+
693+
// Assert
694+
await VerifyOpenApiDocument(builder, options, document =>
695+
{
696+
Assert.Equal(1, executionCount);
697+
698+
var paths = Assert.Single(document.Paths.Values);
699+
var operation = paths.Operations[OperationType.Post];
700+
Assert.NotNull(operation.RequestBody);
701+
702+
Assert.Equal("overloaded x2", operation.Description);
703+
Assert.Collection(operation.RequestBody.Content,
704+
pair => Assert.Equal("application/json", pair.Key),
705+
pair => Assert.Equal("application/x-www-form-urlencoded", pair.Key)
706+
);
707+
});
708+
}
709+
660710
private class ActivatedTransformer : IOpenApiOperationTransformer
661711
{
662712
public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)

0 commit comments

Comments
 (0)