diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..f7b4628b5da2 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.OperationDescriptions.get -> System.Collections.Generic.IReadOnlyDictionary! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.OperationDescriptions.init -> void diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 89a33513afa6..dd12abe2c39b 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -72,10 +72,11 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop Info = GetOpenApiInfo(), Servers = GetOpenApiServers(httpRequest) }; - document.Paths = await GetOpenApiPathsAsync(document, scopedServiceProvider, operationTransformers, schemaTransformers, cancellationToken); + var operationDescriptions = new Dictionary(); + document.Paths = await GetOpenApiPathsAsync(document, scopedServiceProvider, operationTransformers, schemaTransformers, operationDescriptions, cancellationToken); try { - await ApplyTransformersAsync(document, scopedServiceProvider, schemaTransformers, cancellationToken); + await ApplyTransformersAsync(document, scopedServiceProvider, schemaTransformers, operationDescriptions, cancellationToken); } finally @@ -97,7 +98,7 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop return document; } - private async Task ApplyTransformersAsync(OpenApiDocument document, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) + private async Task ApplyTransformersAsync(OpenApiDocument document, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, IReadOnlyDictionary operationDescriptions, CancellationToken cancellationToken) { var documentTransformerContext = new OpenApiDocumentTransformerContext { @@ -105,7 +106,8 @@ private async Task ApplyTransformersAsync(OpenApiDocument document, IServiceProv ApplicationServices = scopedServiceProvider, DescriptionGroups = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items, Document = document, - SchemaTransformers = schemaTransformers + SchemaTransformers = schemaTransformers, + OperationDescriptions = operationDescriptions }; // Use index-based for loop to avoid allocating an enumerator with a foreach. for (var i = 0; i < _options.DocumentTransformers.Count; i++) @@ -246,6 +248,7 @@ private async Task GetOpenApiPathsAsync( IServiceProvider scopedServiceProvider, IOpenApiOperationTransformer[] operationTransformers, IOpenApiSchemaTransformer[] schemaTransformers, + Dictionary operationDescriptions, CancellationToken cancellationToken) { var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items @@ -256,7 +259,7 @@ private async Task GetOpenApiPathsAsync( foreach (var descriptions in descriptionsByPath) { Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null."); - var operations = await GetOperationsAsync(descriptions, document, scopedServiceProvider, operationTransformers, schemaTransformers, cancellationToken); + var operations = await GetOperationsAsync(descriptions, document, scopedServiceProvider, operationTransformers, schemaTransformers, operationDescriptions, cancellationToken); if (operations.Count > 0) { paths.Add(descriptions.Key, new OpenApiPathItem { Operations = operations }); @@ -272,6 +275,7 @@ private async Task> GetOperationsAsync( IServiceProvider scopedServiceProvider, IOpenApiOperationTransformer[] operationTransformers, IOpenApiSchemaTransformer[] schemaTransformers, + Dictionary operationDescriptions, CancellationToken cancellationToken) { var operations = new Dictionary(); @@ -298,7 +302,13 @@ private async Task> GetOperationsAsync( continue; } + if (operations.TryGetValue(method, out var existingOperation)) + { + operationDescriptions.Remove(existingOperation); + } + operations[method] = operation; + operationDescriptions[operation] = description; // Use index-based for loop to avoid allocating an enumerator with a foreach. for (var i = 0; i < operationTransformers.Length; i++) diff --git a/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs index ee2cf766db4b..c1e0d9b277b9 100644 --- a/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs +++ b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs @@ -27,6 +27,11 @@ public sealed class OpenApiDocumentTransformerContext /// public required IServiceProvider ApplicationServices { get; init; } + /// + /// Gets a map of instances to their associated . + /// + public required IReadOnlyDictionary OperationDescriptions { get; init; } + internal IOpenApiSchemaTransformer[] SchemaTransformers { get; init; } = []; // Internal because we expect users to interact with the `Document` provided in 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 c5f599e040d4..b0c7ad0ffda1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/DocumentTransformerTests.cs @@ -389,4 +389,89 @@ internal void TestMethod() { } internal static int InstantiationCount = 0; } + + [Fact] + public async Task DocumentTransformer_CanAccessOperationDescriptions() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => "todo"); + builder.MapGet("/user", () => "user"); + builder.MapPost("/todo", () => { }); + + var expectedPaths = new HashSet(StringComparer.Ordinal) + { + "/todo", + "/user" + }; + + var mappedOperationsCount = 0; + var options = new OpenApiOptions(); + options.AddDocumentTransformer((document, context, cancellationToken) => + { + Assert.Equal(3, context.OperationDescriptions.Count); + + foreach (var (operation, apiDescription) in context.OperationDescriptions) + { + var documentPath = "/" + apiDescription.RelativePath; + Assert.Contains(documentPath, expectedPaths); + + Assert.True(document.Paths.TryGetValue(documentPath, out var pathItem)); + Assert.Contains(operation, pathItem.Operations.Values); + mappedOperationsCount++; + } + + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal(2, document.Paths.Count); + }); + + Assert.Equal(3, mappedOperationsCount); + } + + [Fact] + public async Task DocumentTransformer_CanAccessEndpointMetadataViaOperationDescriptions() + { + var builder = CreateBuilder(); + + builder.MapGet("/public", () => "public") + .WithMetadata(new OperationMarker()); + builder.MapGet("/secret", () => "secret"); + + var options = new OpenApiOptions(); + var publicEndpointHasMarker = false; + var secretEndpointHasMarker = false; + + options.AddDocumentTransformer((document, context, cancellationToken) => + { + Assert.Equal(2, context.OperationDescriptions.Count); + + foreach (var (_, apiDescription) in context.OperationDescriptions) + { + var hasMarker = apiDescription.ActionDescriptor.EndpointMetadata + .Any(m => m is OperationMarker); + + if (apiDescription.RelativePath == "public") + { + publicEndpointHasMarker = hasMarker; + } + else if (apiDescription.RelativePath == "secret") + { + secretEndpointHasMarker = hasMarker; + } + } + + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => { }); + + Assert.True(publicEndpointHasMarker); + Assert.False(secretEndpointHasMarker); + } + + private sealed class OperationMarker; }