diff --git a/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs index 4dd02d9989d0..8b8fda46709a 100644 --- a/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs +++ b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs @@ -4,6 +4,7 @@ using BenchmarkDotNet.Attributes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; @@ -64,6 +65,28 @@ public void DocumentTransformerAsDelegate_Delegate() _documentService = CreateDocumentService(_builder, _options); } + [GlobalSetup(Target = nameof(SchemaTransformer))] + public void SchemaTransformer_Setup() + { + _builder.MapPost("/", (Todo todo) => todo); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseSchemaTransformer((schema, context, token) => + { + 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; + }); + } + _documentService = CreateDocumentService(_builder, _options); + } + [Benchmark] public async Task OperationTransformerAsDelegate() { @@ -82,6 +105,12 @@ public async Task DocumentTransformerAsDelegate() await _documentService.GetOpenApiDocumentAsync(); } + [Benchmark] + public async Task SchemaTransformer() + { + await _documentService.GetOpenApiDocumentAsync(); + } + private class ActivatedTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index f32c9da9334c..ddde07129563 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -11,9 +11,20 @@ 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.OpenApiSchemaTransformerContext +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ApplicationServices.get -> System.IServiceProvider! +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ApplicationServices.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.DocumentName.get -> string! +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.DocumentName.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.OpenApiSchemaTransformerContext() -> void +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ParameterDescription.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.ParameterDescription.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Type.get -> System.Type! +Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Type.init -> void Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions static Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.MapOpenApi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern = "/openapi/{documentName}.json") -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 04402b16b459..05d4f7500970 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -55,7 +55,7 @@ public async Task GetOpenApiDocumentAsync(CancellationToken can var document = new OpenApiDocument { Info = GetOpenApiInfo(), - Paths = GetOpenApiPaths(capturedTags), + Paths = await GetOpenApiPathsAsync(capturedTags, cancellationToken), Tags = [.. capturedTags] }; await ApplyTransformersAsync(document, cancellationToken); @@ -99,7 +99,7 @@ internal OpenApiInfo GetOpenApiInfo() /// the object to support filtering each /// description instance into its appropriate document. /// - private OpenApiPaths GetOpenApiPaths(HashSet capturedTags) + private async Task GetOpenApiPathsAsync(HashSet capturedTags, CancellationToken cancellationToken) { var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items .SelectMany(group => group.Items) @@ -109,17 +109,17 @@ private OpenApiPaths GetOpenApiPaths(HashSet capturedTags) foreach (var descriptions in descriptionsByPath) { Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null."); - paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions, capturedTags) }); + paths.Add(descriptions.Key, new OpenApiPathItem { Operations = await GetOperationsAsync(descriptions, capturedTags, cancellationToken) }); } return paths; } - private Dictionary GetOperations(IGrouping descriptions, HashSet capturedTags) + private async Task> GetOperationsAsync(IGrouping descriptions, HashSet capturedTags, CancellationToken cancellationToken) { var operations = new Dictionary(); foreach (var description in descriptions) { - var operation = GetOperation(description, capturedTags); + var operation = await GetOperationAsync(description, capturedTags, cancellationToken); operation.Extensions.Add(OpenApiConstants.DescriptionId, new OpenApiString(description.ActionDescriptor.Id)); _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, new OpenApiOperationTransformerContext { @@ -132,7 +132,7 @@ private Dictionary GetOperations(IGrouping capturedTags) + private async Task GetOperationAsync(ApiDescription description, HashSet capturedTags, CancellationToken cancellationToken) { var tags = GetTags(description); if (tags != null) @@ -147,9 +147,9 @@ private OpenApiOperation GetOperation(ApiDescription description, HashSet GetResponsesAsync(ApiDescription description, CancellationToken cancellationToken) { // OpenAPI requires that each operation have a response, usually a successful one. // if there are no response types defined, we assume a successful 200 OK response @@ -186,7 +186,7 @@ private OpenApiResponses GetResponses(ApiDescription description) { return new OpenApiResponses { - ["200"] = GetResponse(description, StatusCodes.Status200OK, _defaultApiResponseType) + ["200"] = await GetResponseAsync(description, StatusCodes.Status200OK, _defaultApiResponseType, cancellationToken) }; } @@ -200,12 +200,12 @@ private OpenApiResponses GetResponses(ApiDescription description) var responseKey = responseType.IsDefaultResponse ? OpenApiConstants.DefaultOpenApiResponseKey : responseType.StatusCode.ToString(CultureInfo.InvariantCulture); - responses.Add(responseKey, GetResponse(description, responseType.StatusCode, responseType)); + responses.Add(responseKey, await GetResponseAsync(description, responseType.StatusCode, responseType, cancellationToken)); } return responses; } - private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType) + private async Task GetResponseAsync(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType, CancellationToken cancellationToken) { var description = ReasonPhrases.GetReasonPhrase(statusCode); var response = new OpenApiResponse @@ -222,7 +222,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod .Select(responseFormat => responseFormat.MediaType); foreach (var contentType in apiResponseFormatContentTypes) { - var schema = apiResponseType.Type is { } type ? _componentService.GetOrCreateSchema(type) : new OpenApiSchema(); + var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(type, null, cancellationToken) : new OpenApiSchema(); response.Content[contentType] = new OpenApiMediaType { Schema = schema }; } @@ -240,7 +240,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod return response; } - private List? GetParameters(ApiDescription description) + private async Task?> GetParametersAsync(ApiDescription description, CancellationToken cancellationToken) { List? parameters = null; foreach (var parameter in description.ParameterDescriptions) @@ -265,7 +265,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod // Per the OpenAPI specification, parameters that are sourced from the path // are always required, regardless of the requiredness status of the parameter. Required = parameter.Source == BindingSource.Path || parameter.IsRequired, - Schema = _componentService.GetOrCreateSchema(parameter.Type, parameter), + Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, parameter, cancellationToken), }; parameters ??= []; parameters.Add(openApiParameter); @@ -273,24 +273,24 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod return parameters; } - private OpenApiRequestBody? GetRequestBody(ApiDescription description) + private async Task GetRequestBodyAsync(ApiDescription description, CancellationToken cancellationToken) { // Only one parameter can be bound from the body in each request. if (description.TryGetBodyParameter(out var bodyParameter)) { - return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter); + return await GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter, cancellationToken); } // If there are no body parameters, check for form parameters. // Note: Form parameters and body parameters cannot exist simultaneously // in the same endpoint. if (description.TryGetFormParameters(out var formParameters)) { - return GetFormRequestBody(description.SupportedRequestFormats, formParameters); + return await GetFormRequestBody(description.SupportedRequestFormats, formParameters, cancellationToken); } return null; } - private OpenApiRequestBody GetFormRequestBody(IList supportedRequestFormats, IEnumerable formParameters) + private async Task GetFormRequestBody(IList supportedRequestFormats, IEnumerable formParameters, CancellationToken cancellationToken) { if (supportedRequestFormats.Count == 0) { @@ -325,7 +325,7 @@ private OpenApiRequestBody GetFormRequestBody(IList supportedR if (parameter.All(parameter => parameter.ModelMetadata.ContainerType is null)) { var description = parameter.Single(); - var parameterSchema = _componentService.GetOrCreateSchema(description.Type); + var parameterSchema = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken); // Form files are keyed by their parameter name so we must capture the parameter name // as a property in the schema. if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection)) @@ -388,7 +388,7 @@ private OpenApiRequestBody GetFormRequestBody(IList supportedR var propertySchema = new OpenApiSchema { Type = "object", Properties = new Dictionary() }; foreach (var description in parameter) { - propertySchema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type); + propertySchema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken); } schema.AllOf.Add(propertySchema); } @@ -396,7 +396,7 @@ private OpenApiRequestBody GetFormRequestBody(IList supportedR { foreach (var description in parameter) { - schema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type); + schema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(description.Type, null, cancellationToken); } } } @@ -415,7 +415,7 @@ private OpenApiRequestBody GetFormRequestBody(IList supportedR return requestBody; } - private OpenApiRequestBody GetJsonRequestBody(IList supportedRequestFormats, ApiParameterDescription bodyParameter) + private async Task GetJsonRequestBody(IList supportedRequestFormats, ApiParameterDescription bodyParameter, CancellationToken cancellationToken) { if (supportedRequestFormats.Count == 0) { @@ -442,7 +442,7 @@ private OpenApiRequestBody GetJsonRequestBody(IList supportedR foreach (var requestForm in supportedRequestFormats) { var contentType = requestForm.MediaType; - requestBody.Content[contentType] = new OpenApiMediaType { Schema = _componentService.GetOrCreateSchema(bodyParameter.Type) }; + requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(bodyParameter.Type, bodyParameter, cancellationToken) }; } return requestBody; diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index fba727315660..a9162196decd 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.OpenApi; public sealed class OpenApiOptions { internal readonly List DocumentTransformers = []; + internal readonly List> SchemaTransformers = []; /// /// Initializes a new instance of the class @@ -89,4 +90,17 @@ public OpenApiOptions UseOperationTransformer(Func + /// Registers a given delegate as a schema transformer on the current instance. + /// + /// The delegate representing the schema transformer. + /// The instance for further customization. + public OpenApiOptions UseSchemaTransformer(Func transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + SchemaTransformers.Add(transformer); + return this; + } } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 119634e4f9f0..1bdb8317a92a 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.IO.Pipelines; using System.Text.Json; using System.Text.Json.Nodes; @@ -21,9 +22,14 @@ namespace Microsoft.AspNetCore.OpenApi; /// an OpenAPI document. In particular, this is the API that is used to /// interact with the JSON schemas that are managed by a given OpenAPI document. /// -internal sealed class OpenApiSchemaService([ServiceKey] string documentName, IOptions jsonOptions, IServiceProvider serviceProvider) +internal sealed class OpenApiSchemaService( + [ServiceKey] string documentName, + IOptions jsonOptions, + IServiceProvider serviceProvider, + IOptionsMonitor optionsMonitor) { private readonly OpenApiSchemaStore _schemaStore = serviceProvider.GetRequiredKeyedService(documentName); + private readonly OpenApiOptions _openApiOptions = optionsMonitor.Get(documentName); private readonly JsonSerializerOptions _jsonSerializerOptions = jsonOptions.Value.SerializerOptions; private readonly JsonSchemaMapperConfiguration _configuration = new() { @@ -57,7 +63,7 @@ internal sealed class OpenApiSchemaService([ServiceKey] string documentName, IOp } }; - internal OpenApiSchema GetOrCreateSchema(Type type, ApiParameterDescription? parameterDescription = null) + internal async Task GetOrCreateSchemaAsync(Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) { var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription && parameterDescription.ModelMetadata.PropertyName is null @@ -68,7 +74,26 @@ internal OpenApiSchema GetOrCreateSchema(Type type, ApiParameterDescription? par schemaAsJsonObject.ApplyParameterInfo(parameterDescription); } var deserializedSchema = JsonSerializer.Deserialize(schemaAsJsonObject, OpenApiJsonSchemaContext.Default.OpenApiJsonSchema); - return deserializedSchema != null ? deserializedSchema.Schema : new OpenApiSchema(); + Debug.Assert(deserializedSchema != null, "The schema should have been deserialized successfully and materialize a non-null value."); + var schema = deserializedSchema.Schema; + await ApplySchemaTransformersAsync(schema, type, parameterDescription, cancellationToken); + return schema; + } + + internal async Task ApplySchemaTransformersAsync(OpenApiSchema schema, Type type, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) + { + var context = new OpenApiSchemaTransformerContext + { + DocumentName = documentName, + Type = type, + ParameterDescription = parameterDescription, + ApplicationServices = serviceProvider + }; + for (var i = 0; i < _openApiOptions.SchemaTransformers.Count; i++) + { + var transformer = _openApiOptions.SchemaTransformers[i]; + await transformer(schema, context, cancellationToken); + } } private JsonObject CreateSchema(OpenApiSchemaKey key) diff --git a/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs new file mode 100644 index 000000000000..b915978ff35b --- /dev/null +++ b/src/OpenApi/src/Transformers/OpenApiSchemaTransformerContext.cs @@ -0,0 +1,34 @@ +// 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.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents the context in which an OpenAPI schema transformer is executed. +/// +public sealed class OpenApiSchemaTransformerContext +{ + /// + /// Gets the name of the associated OpenAPI document. + /// + public required string DocumentName { get; init; } + + /// + /// Gets the associated with the current . + /// + public required Type Type { get; init; } + + /// + /// Gets the associated with the target schema. + /// Null when processing an OpenAPI schema for a response type. + /// + public required ApiParameterDescription? ParameterDescription { get; init; } + + /// + /// Gets the application services associated with the current document the target schema is in. + /// + public required IServiceProvider ApplicationServices { get; init; } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs index f6a2d749294e..9b72ee79e8df 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs @@ -21,13 +21,13 @@ public abstract class OpenApiDocumentServiceTestBase { - public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action verifyOpenApiDocument) - => await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument); + 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, OpenApiOptions openApiOptions, Action verifyOpenApiDocument) + public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions, Action verifyOpenApiDocument, CancellationToken cancellationToken = default) { var documentService = CreateDocumentService(builder, openApiOptions); - var document = await documentService.GetOpenApiDocumentAsync(); + var document = await documentService.GetOpenApiDocumentAsync(cancellationToken); verifyOpenApiDocument(document); } @@ -73,6 +73,8 @@ internal static OpenApiDocumentService CreateDocumentService(ActionDescriptor ac var openApiOptions = new Mock>(); openApiOptions.Setup(o => o.Get(It.IsAny())).Returns(new OpenApiOptions()); + var schemaService = new OpenApiSchemaService("Test", Options.Create(new Microsoft.AspNetCore.Http.Json.JsonOptions()), builder.ServiceProvider, openApiOptions.Object); + ((TestServiceProvider)builder.ServiceProvider).TestSchemaService = schemaService; var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, openApiOptions.Object, builder.ServiceProvider); ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; @@ -97,6 +99,8 @@ internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuild var apiDescriptionGroupCollectionProvider = CreateApiDescriptionGroupCollectionProvider(context.Results); + var schemaService = new OpenApiSchemaService("Test", Options.Create(new Microsoft.AspNetCore.Http.Json.JsonOptions()), builder.ServiceProvider, options.Object); + ((TestServiceProvider)builder.ServiceProvider).TestSchemaService = schemaService; var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, builder.ServiceProvider); ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; @@ -214,33 +218,27 @@ private class TestServiceProvider : IServiceProvider, IKeyedServiceProvider private IKeyedServiceProvider _serviceProvider; internal OpenApiDocumentService TestDocumentService { get; set; } internal OpenApiSchemaStore TestSchemaStoreService { get; } = new OpenApiSchemaStore(); - private OpenApiSchemaService _testSchemaService; + internal OpenApiSchemaService TestSchemaService { get; set; } public void SetInternalServiceProvider(IServiceCollection serviceCollection) { serviceCollection.AddKeyedSingleton("Test"); + serviceCollection.Configure("Test", options => + { + options.DocumentName = "Test"; + }); _serviceProvider = serviceCollection.BuildServiceProvider(); - _testSchemaService = new OpenApiSchemaService( - "Test", - Options.Create(new Microsoft.AspNetCore.Http.Json.JsonOptions()), - _serviceProvider - ); } public object GetKeyedService(Type serviceType, object serviceKey) { - if (serviceType == typeof(OpenApiDocumentService)) { return TestDocumentService; } if (serviceType == typeof(OpenApiSchemaService)) { - return _testSchemaService; - } - if (serviceType == typeof(OpenApiSchemaService)) - { - return _testSchemaService; + return TestSchemaService; } if (serviceType == typeof(OpenApiSchemaStore)) { @@ -258,12 +256,11 @@ public object GetRequiredKeyedService(Type serviceType, object serviceKey) } if (serviceType == typeof(OpenApiSchemaService)) { - return _testSchemaService; + return TestSchemaService; } - - if (serviceType == typeof(OpenApiSchemaService)) + if (serviceType == typeof(OpenApiSchemaStore)) { - return _testSchemaService; + return TestSchemaStoreService; } return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); diff --git a/src/OpenApi/test/Transformers/SchemaTransformerTests.cs b/src/OpenApi/test/Transformers/SchemaTransformerTests.cs new file mode 100644 index 000000000000..841c4fd321e4 --- /dev/null +++ b/src/OpenApi/test/Transformers/SchemaTransformerTests.cs @@ -0,0 +1,174 @@ +// 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.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +public class SchemaTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task SchemaTransformer_CanAccessTypeAndParameterDescriptionForParameter() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + Assert.Equal(typeof(Todo), context.Type); + Assert.Equal("todo", context.ParameterDescription.Name); + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => { }); + } + + [Fact] + public async Task SchemaTransformer_CanAccessTypeForResponse() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + Assert.Equal(typeof(Todo), context.Type); + Assert.Null(context.ParameterDescription); + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => { }); + } + + [Fact] + public async Task SchemaTransformer_CanAccessApplicationServicesAndDocumentName() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + var service = context.ApplicationServices.GetKeyedService(context.DocumentName); + Assert.NotNull(service); + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => { }); + } + + [Fact] + public async Task SchemaTransformer_RespectsCancellationToken() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + Assert.Equal(cts.Token, cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => { }, cts.Token); + } + + [Fact] + public async Task SchemaTransformer_RunsInRegisteredOrder() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + schema.Extensions["x-my-extension"] = new OpenApiString("1"); + return Task.CompletedTask; + }); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + Assert.Equal("1", ((OpenApiString)schema.Extensions["x-my-extension"]).Value); + schema.Extensions["x-my-extension"] = new OpenApiString("2"); + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + var operation = Assert.Single(document.Paths.Values).Operations.Values.Single(); + var schema = operation.RequestBody.Content["application/json"].Schema; + Assert.Equal("2", ((OpenApiString)schema.Extensions["x-my-extension"]).Value); + }); + } + + [Fact] + public async Task SchemaTransformer_OnTypeModifiesBothRequestAndResponse() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + if (context.Type == typeof(Todo)) + { + schema.Extensions["x-my-extension"] = new OpenApiString("1"); + } + return Task.CompletedTask; + }); + + 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; + 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; + Assert.Equal("1", ((OpenApiString)responseSchema.Extensions["x-my-extension"]).Value); + }); + } + + [Fact] + public async Task SchemaTransformer_WithDescriptionOnlyModifiesParameter() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + if (context.Type == typeof(Todo) && context.ParameterDescription is not null) + { + schema.Extensions["x-my-extension"] = new OpenApiString(context.ParameterDescription.Name); + } + return Task.CompletedTask; + }); + + 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; + Assert.Equal("todo", ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema; + Assert.False(responseSchema.Extensions.TryGetValue("x-my-extension", out var _)); + }); + } +}