diff --git a/Modules/OpenApi/Extensions.cs b/Modules/OpenApi/Extensions.cs index a6adbbdbc..a820c4686 100644 --- a/Modules/OpenApi/Extensions.cs +++ b/Modules/OpenApi/Extensions.cs @@ -1,5 +1,11 @@ -using GenHTTP.Api.Content; +using System; +using System.Collections.Generic; + +using GenHTTP.Api.Content; using GenHTTP.Modules.OpenApi.Discovery; +using GenHTTP.Modules.OpenApi.Handler; + +using NSwag; namespace GenHTTP.Modules.OpenApi; @@ -27,4 +33,98 @@ public static T AddOpenApi(this T builder, ApiDiscoveryRegistryBuilder regist return builder; } + /// + /// Configures the generated OpenAPI specification to describe an + /// API-key based authentication scheme. + /// + /// + /// This helper registers an apiKey security scheme that reads + /// the key from a given HTTP header and marks all matching paths as + /// requiring that scheme. A 401 Unauthorized response is added + /// to such operations if it is not already present. + /// + /// + /// The OpenAPI concern builder returned by . + /// + /// + /// Logical name of the security scheme to be referenced by security requirements. + /// Defaults to "X-API-Key". + /// + /// + /// Name of the HTTP header that will contain the API key. + /// Defaults to "X-API-Key". + /// + /// + /// Optional predicate used to determine for which paths the security + /// requirement should be applied. If null, the requirement is + /// added for all paths. + /// + /// + /// The same instance to allow fluent configuration. + /// + public static OpenApiConcernBuilder WithApiKeyAuthentication( + this OpenApiConcernBuilder builder, + string schemeName = "X-API-Key", + string headerName = "X-API-Key", + Func? includePath = null) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + + includePath ??= _ => true; + + return builder.PostProcessor((request, doc) => + { + var securityDefinitions = doc.SecurityDefinitions; + + // Register the API key security scheme if not already present + if (securityDefinitions != null && !securityDefinitions.ContainsKey(schemeName)) + { + securityDefinitions.Add(schemeName, new OpenApiSecurityScheme + { + Name = headerName, + Type = OpenApiSecuritySchemeType.ApiKey, + In = OpenApiSecurityApiKeyLocation.Header, + }); + } + + if (doc.Paths is null) + { + return; + } + + var requirement = new OpenApiSecurityRequirement + { + { schemeName, Array.Empty() } + }; + + foreach (var pathEntry in doc.Paths) + { + var path = pathEntry.Key; + var pathItem = pathEntry.Value; + + if (!includePath(path) || pathItem is null) + { + continue; + } + + foreach (var operation in pathItem.Values) + { + if (operation is null) continue; + + operation.Security ??= new List(); + operation.Security.Add(requirement); + + if (!operation.Responses.ContainsKey("401")) + { + operation.Responses.Add("401", new OpenApiResponse + { + Description = "Unauthorized" + }); + } + } + } + }); + } + + } diff --git a/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs b/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs index 5a729d81b..00dfa4737 100644 --- a/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs +++ b/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs @@ -65,10 +65,19 @@ public OpenApiConcernBuilder Caching(bool enabled) /// Registers a function that will be called when an OpenAPI document has been /// generated, directly before it is served to the client. /// + /// + /// This method can be called multiple times. All registered post processors + /// will be invoked in the order of registration. + /// /// The method to be invoked to adjust the generated document public OpenApiConcernBuilder PostProcessor(Action action) { - _postProcessor = action; + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + _postProcessor += action; return this; } diff --git a/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs b/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs index 27bd9ed03..1b3c8cfa5 100644 --- a/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs +++ b/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs @@ -5,6 +5,8 @@ using GenHTTP.Modules.Layouting; using GenHTTP.Modules.OpenApi; using GenHTTP.Modules.OpenApi.Discovery; +using System.Linq; +using NSwag; using OpenApiDocument = NSwag.OpenApiDocument; namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; @@ -63,6 +65,46 @@ public async Task TestSamePathWithDifferentMethods(TestEngine engine) Assert.IsTrue(operations?.ContainsKey(HttpMethod.Put)); } + [TestMethod] + [MultiEngineTest] + public async Task TestApiKeyAuthenticationIsReflected(TestEngine engine) + { + var api = Inline.Create() + .Get("/secure", () => 42) + .Add(ApiDescription.Create() + .WithApiKeyAuthentication( + schemeName: "X-API-Key", + headerName: "X-API-Key", + includePath: _ => true)); + + var result = await api.GetOpenApiAsync(engine); + var doc = result.Document; + Assert.IsNotNull(doc, "OpenAPI document should not be null."); + + var paths = doc!.Paths; + Assert.IsNotNull(paths, "Paths collection should not be null."); + + Assert.IsTrue(paths!.TryGetValue("/secure", out var pathItem), + "Path '/secure' should be present."); + Assert.IsNotNull(pathItem, "Path item for '/secure' should not be null."); + + var operations = pathItem!.Operations; + Assert.IsNotNull(operations, "Operations collection should not be null."); + + Assert.IsTrue(operations!.TryGetValue(HttpMethod.Get, out var operation), + "GET operation for '/secure' should be present."); + Assert.IsNotNull(operation, "Operation for GET '/secure' should not be null."); + var responses = operation!.Responses; + Assert.IsNotNull(responses, "Responses collection should not be null."); + Assert.IsTrue(responses!.ContainsKey("401"), + "401 Unauthorized response should be documented."); + + var security = operation.Security; + Assert.IsNotNull(security, "Security requirements should be set on the operation."); + Assert.IsTrue(security!.Any(), + "There should be at least one security requirement."); + } + #region Supporting data structures public class CustomExplorer : IApiExplorer