Skip to content

Commit 206b0ae

Browse files
authored
Add support for OpenAPI schema transformers (#56093)
1 parent 60a8ba0 commit 206b0ae

File tree

8 files changed

+332
-48
lines changed

8 files changed

+332
-48
lines changed

src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs

+29
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using BenchmarkDotNet.Attributes;
55
using Microsoft.AspNetCore.Builder;
66
using Microsoft.AspNetCore.Routing;
7+
using Microsoft.OpenApi.Any;
78
using Microsoft.OpenApi.Models;
89

910
namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks;
@@ -64,6 +65,28 @@ public void DocumentTransformerAsDelegate_Delegate()
6465
_documentService = CreateDocumentService(_builder, _options);
6566
}
6667

68+
[GlobalSetup(Target = nameof(SchemaTransformer))]
69+
public void SchemaTransformer_Setup()
70+
{
71+
_builder.MapPost("/", (Todo todo) => todo);
72+
for (var i = 0; i <= TransformerCount; i++)
73+
{
74+
_options.UseSchemaTransformer((schema, context, token) =>
75+
{
76+
if (context.Type == typeof(Todo) && context.ParameterDescription != null)
77+
{
78+
schema.Extensions["x-my-extension"] = new OpenApiString(context.ParameterDescription.Name);
79+
}
80+
else
81+
{
82+
schema.Extensions["x-my-extension"] = new OpenApiString("response");
83+
}
84+
return Task.CompletedTask;
85+
});
86+
}
87+
_documentService = CreateDocumentService(_builder, _options);
88+
}
89+
6790
[Benchmark]
6891
public async Task OperationTransformerAsDelegate()
6992
{
@@ -82,6 +105,12 @@ public async Task DocumentTransformerAsDelegate()
82105
await _documentService.GetOpenApiDocumentAsync();
83106
}
84107

108+
[Benchmark]
109+
public async Task SchemaTransformer()
110+
{
111+
await _documentService.GetOpenApiDocumentAsync();
112+
}
113+
85114
private class ActivatedTransformer : IOpenApiDocumentTransformer
86115
{
87116
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)

src/OpenApi/src/PublicAPI.Unshipped.txt

+11
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,20 @@ Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.set -> void
1111
Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.get -> System.Func<Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription!, bool>!
1212
Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.set -> void
1313
Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseOperationTransformer(System.Func<Microsoft.OpenApi.Models.OpenApiOperation!, Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions!
14+
Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseSchemaTransformer(System.Func<Microsoft.OpenApi.Models.OpenApiSchema!, Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions!
1415
Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions!
1516
Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(System.Func<Microsoft.OpenApi.Models.OpenApiDocument!, Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions!
1617
Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer<TTransformerType>() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions!
18+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext
19+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ApplicationServices.get -> System.IServiceProvider!
20+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ApplicationServices.init -> void
21+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.DocumentName.get -> string!
22+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.DocumentName.init -> void
23+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.OpenApiSchemaTransformerContext() -> void
24+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ParameterDescription.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription?
25+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ParameterDescription.init -> void
26+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Type.get -> System.Type!
27+
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Type.init -> void
1728
Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions
1829
static Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.MapOpenApi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern = "/openapi/{documentName}.json") -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
1930
static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!

src/OpenApi/src/Services/OpenApiDocumentService.cs

+25-25
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public async Task<OpenApiDocument> GetOpenApiDocumentAsync(CancellationToken can
5555
var document = new OpenApiDocument
5656
{
5757
Info = GetOpenApiInfo(),
58-
Paths = GetOpenApiPaths(capturedTags),
58+
Paths = await GetOpenApiPathsAsync(capturedTags, cancellationToken),
5959
Tags = [.. capturedTags]
6060
};
6161
await ApplyTransformersAsync(document, cancellationToken);
@@ -99,7 +99,7 @@ internal OpenApiInfo GetOpenApiInfo()
9999
/// the object to support filtering each
100100
/// description instance into its appropriate document.
101101
/// </remarks>
102-
private OpenApiPaths GetOpenApiPaths(HashSet<OpenApiTag> capturedTags)
102+
private async Task<OpenApiPaths> GetOpenApiPathsAsync(HashSet<OpenApiTag> capturedTags, CancellationToken cancellationToken)
103103
{
104104
var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items
105105
.SelectMany(group => group.Items)
@@ -109,17 +109,17 @@ private OpenApiPaths GetOpenApiPaths(HashSet<OpenApiTag> capturedTags)
109109
foreach (var descriptions in descriptionsByPath)
110110
{
111111
Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null.");
112-
paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions, capturedTags) });
112+
paths.Add(descriptions.Key, new OpenApiPathItem { Operations = await GetOperationsAsync(descriptions, capturedTags, cancellationToken) });
113113
}
114114
return paths;
115115
}
116116

117-
private Dictionary<OperationType, OpenApiOperation> GetOperations(IGrouping<string?, ApiDescription> descriptions, HashSet<OpenApiTag> capturedTags)
117+
private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsync(IGrouping<string?, ApiDescription> descriptions, HashSet<OpenApiTag> capturedTags, CancellationToken cancellationToken)
118118
{
119119
var operations = new Dictionary<OperationType, OpenApiOperation>();
120120
foreach (var description in descriptions)
121121
{
122-
var operation = GetOperation(description, capturedTags);
122+
var operation = await GetOperationAsync(description, capturedTags, cancellationToken);
123123
operation.Extensions.Add(OpenApiConstants.DescriptionId, new OpenApiString(description.ActionDescriptor.Id));
124124
_operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, new OpenApiOperationTransformerContext
125125
{
@@ -132,7 +132,7 @@ private Dictionary<OperationType, OpenApiOperation> GetOperations(IGrouping<stri
132132
return operations;
133133
}
134134

135-
private OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenApiTag> capturedTags)
135+
private async Task<OpenApiOperation> GetOperationAsync(ApiDescription description, HashSet<OpenApiTag> capturedTags, CancellationToken cancellationToken)
136136
{
137137
var tags = GetTags(description);
138138
if (tags != null)
@@ -147,9 +147,9 @@ private OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenAp
147147
OperationId = GetOperationId(description),
148148
Summary = GetSummary(description),
149149
Description = GetDescription(description),
150-
Responses = GetResponses(description),
151-
Parameters = GetParameters(description),
152-
RequestBody = GetRequestBody(description),
150+
Responses = await GetResponsesAsync(description, cancellationToken),
151+
Parameters = await GetParametersAsync(description, cancellationToken),
152+
RequestBody = await GetRequestBodyAsync(description, cancellationToken),
153153
Tags = tags,
154154
};
155155
return operation;
@@ -177,7 +177,7 @@ private OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenAp
177177
return [new OpenApiTag { Name = description.ActionDescriptor.RouteValues["controller"] }];
178178
}
179179

180-
private OpenApiResponses GetResponses(ApiDescription description)
180+
private async Task<OpenApiResponses> GetResponsesAsync(ApiDescription description, CancellationToken cancellationToken)
181181
{
182182
// OpenAPI requires that each operation have a response, usually a successful one.
183183
// if there are no response types defined, we assume a successful 200 OK response
@@ -186,7 +186,7 @@ private OpenApiResponses GetResponses(ApiDescription description)
186186
{
187187
return new OpenApiResponses
188188
{
189-
["200"] = GetResponse(description, StatusCodes.Status200OK, _defaultApiResponseType)
189+
["200"] = await GetResponseAsync(description, StatusCodes.Status200OK, _defaultApiResponseType, cancellationToken)
190190
};
191191
}
192192

@@ -200,12 +200,12 @@ private OpenApiResponses GetResponses(ApiDescription description)
200200
var responseKey = responseType.IsDefaultResponse
201201
? OpenApiConstants.DefaultOpenApiResponseKey
202202
: responseType.StatusCode.ToString(CultureInfo.InvariantCulture);
203-
responses.Add(responseKey, GetResponse(description, responseType.StatusCode, responseType));
203+
responses.Add(responseKey, await GetResponseAsync(description, responseType.StatusCode, responseType, cancellationToken));
204204
}
205205
return responses;
206206
}
207207

208-
private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType)
208+
private async Task<OpenApiResponse> GetResponseAsync(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType, CancellationToken cancellationToken)
209209
{
210210
var description = ReasonPhrases.GetReasonPhrase(statusCode);
211211
var response = new OpenApiResponse
@@ -222,7 +222,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod
222222
.Select(responseFormat => responseFormat.MediaType);
223223
foreach (var contentType in apiResponseFormatContentTypes)
224224
{
225-
var schema = apiResponseType.Type is { } type ? _componentService.GetOrCreateSchema(type) : new OpenApiSchema();
225+
var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(type, null, cancellationToken) : new OpenApiSchema();
226226
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
227227
}
228228

@@ -240,7 +240,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod
240240
return response;
241241
}
242242

243-
private List<OpenApiParameter>? GetParameters(ApiDescription description)
243+
private async Task<List<OpenApiParameter>?> GetParametersAsync(ApiDescription description, CancellationToken cancellationToken)
244244
{
245245
List<OpenApiParameter>? parameters = null;
246246
foreach (var parameter in description.ParameterDescriptions)
@@ -265,32 +265,32 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod
265265
// Per the OpenAPI specification, parameters that are sourced from the path
266266
// are always required, regardless of the requiredness status of the parameter.
267267
Required = parameter.Source == BindingSource.Path || parameter.IsRequired,
268-
Schema = _componentService.GetOrCreateSchema(parameter.Type, parameter),
268+
Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, parameter, cancellationToken),
269269
};
270270
parameters ??= [];
271271
parameters.Add(openApiParameter);
272272
}
273273
return parameters;
274274
}
275275

276-
private OpenApiRequestBody? GetRequestBody(ApiDescription description)
276+
private async Task<OpenApiRequestBody?> GetRequestBodyAsync(ApiDescription description, CancellationToken cancellationToken)
277277
{
278278
// Only one parameter can be bound from the body in each request.
279279
if (description.TryGetBodyParameter(out var bodyParameter))
280280
{
281-
return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter);
281+
return await GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter, cancellationToken);
282282
}
283283
// If there are no body parameters, check for form parameters.
284284
// Note: Form parameters and body parameters cannot exist simultaneously
285285
// in the same endpoint.
286286
if (description.TryGetFormParameters(out var formParameters))
287287
{
288-
return GetFormRequestBody(description.SupportedRequestFormats, formParameters);
288+
return await GetFormRequestBody(description.SupportedRequestFormats, formParameters, cancellationToken);
289289
}
290290
return null;
291291
}
292292

293-
private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedRequestFormats, IEnumerable<ApiParameterDescription> formParameters)
293+
private async Task<OpenApiRequestBody> GetFormRequestBody(IList<ApiRequestFormat> supportedRequestFormats, IEnumerable<ApiParameterDescription> formParameters, CancellationToken cancellationToken)
294294
{
295295
if (supportedRequestFormats.Count == 0)
296296
{
@@ -325,7 +325,7 @@ private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedR
325325
if (parameter.All(parameter => parameter.ModelMetadata.ContainerType is null))
326326
{
327327
var description = parameter.Single();
328-
var parameterSchema = _componentService.GetOrCreateSchema(description.Type);
328+
var parameterSchema = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken);
329329
// Form files are keyed by their parameter name so we must capture the parameter name
330330
// as a property in the schema.
331331
if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection))
@@ -388,15 +388,15 @@ private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedR
388388
var propertySchema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
389389
foreach (var description in parameter)
390390
{
391-
propertySchema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type);
391+
propertySchema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken);
392392
}
393393
schema.AllOf.Add(propertySchema);
394394
}
395395
else
396396
{
397397
foreach (var description in parameter)
398398
{
399-
schema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type);
399+
schema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken);
400400
}
401401
}
402402
}
@@ -415,7 +415,7 @@ private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedR
415415
return requestBody;
416416
}
417417

418-
private OpenApiRequestBody GetJsonRequestBody(IList<ApiRequestFormat> supportedRequestFormats, ApiParameterDescription bodyParameter)
418+
private async Task<OpenApiRequestBody> GetJsonRequestBody(IList<ApiRequestFormat> supportedRequestFormats, ApiParameterDescription bodyParameter, CancellationToken cancellationToken)
419419
{
420420
if (supportedRequestFormats.Count == 0)
421421
{
@@ -442,7 +442,7 @@ private OpenApiRequestBody GetJsonRequestBody(IList<ApiRequestFormat> supportedR
442442
foreach (var requestForm in supportedRequestFormats)
443443
{
444444
var contentType = requestForm.MediaType;
445-
requestBody.Content[contentType] = new OpenApiMediaType { Schema = _componentService.GetOrCreateSchema(bodyParameter.Type) };
445+
requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(bodyParameter.Type, bodyParameter, cancellationToken) };
446446
}
447447

448448
return requestBody;

src/OpenApi/src/Services/OpenApiOptions.cs

+14
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.OpenApi;
1414
public sealed class OpenApiOptions
1515
{
1616
internal readonly List<IOpenApiDocumentTransformer> DocumentTransformers = [];
17+
internal readonly List<Func<OpenApiSchema, OpenApiSchemaTransformerContext, CancellationToken, Task>> SchemaTransformers = [];
1718

1819
/// <summary>
1920
/// Initializes a new instance of the <see cref="OpenApiOptions"/> class
@@ -89,4 +90,17 @@ public OpenApiOptions UseOperationTransformer(Func<OpenApiOperation, OpenApiOper
8990
DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer));
9091
return this;
9192
}
93+
94+
/// <summary>
95+
/// Registers a given delegate as a schema transformer on the current <see cref="OpenApiOptions"/> instance.
96+
/// </summary>
97+
/// <param name="transformer">The delegate representing the schema transformer.</param>
98+
/// <returns>The <see cref="OpenApiOptions"/> instance for further customization.</returns>
99+
public OpenApiOptions UseSchemaTransformer(Func<OpenApiSchema, OpenApiSchemaTransformerContext, CancellationToken, Task> transformer)
100+
{
101+
ArgumentNullException.ThrowIfNull(transformer, nameof(transformer));
102+
103+
SchemaTransformers.Add(transformer);
104+
return this;
105+
}
92106
}

0 commit comments

Comments
 (0)