From cf7d7e7b922e972932a22cb0d475a09d92b2aa3d Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 18 Oct 2025 22:54:48 +0100 Subject: [PATCH 1/4] Add Dev Container config --- .devcontainer/Dockerfile | 6 +++++ .devcontainer/devcontainer.json | 45 ++++++++++++++++++++++++++++++++ .devcontainer/docker-compose.yml | 35 +++++++++++++++++++++++++ .github/dependabot.yml | 12 +++++++++ .sourcekit-lsp/config.json | 3 +++ 5 files changed, 101 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .github/dependabot.yml create mode 100644 .sourcekit-lsp/config.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..98432cb --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,6 @@ +# Note: You can use any Debian/Ubuntu based image you want. +FROM swift:6.2.0 + +# [Optional] Uncomment this section to install additional OS packages. +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends make \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f46dd2e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,45 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker-compose +{ + "name": "Docker from Docker Compose", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Use this environment variable if you need to bind mount your local source code into a new container. + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "latest", + "enableNonRootDocker": "true", + "moby": "true" + }, + "ghcr.io/devcontainers/features/aws-cli:1": {} + }, + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "swiftlang.swift-vscode" + ] + } + } + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "docker --version", + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..3b7b7ee --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3' + +services: + app: + build: + context: . + dockerfile: Dockerfile + + volumes: + # Forwards the local Docker socket to the container. + - /var/run/docker.sock:/var/run/docker-host.sock + # Update this to wherever you want VS Code to mount the folder of your project + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + entrypoint: /usr/local/share/docker-init.sh + depends_on: + - localstack + environment: + - LOCALSTACK_ENDPOINT=http://localstack:4566 + - AWS_ACCESS_KEY_ID=test + - AWS_SECRET_ACCESS_KEY=test + - AWS_REGION=us-east-1 + command: sleep infinity + + # Uncomment the next four lines if you will use a ptrace-based debuggers like C++, Go, and Rust. + cap_add: + - SYS_PTRACE + security_opt: + - seccomp:unconfined + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + localstack: + image: localstack/localstack \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f33a02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.sourcekit-lsp/config.json b/.sourcekit-lsp/config.json new file mode 100644 index 0000000..0a93f4c --- /dev/null +++ b/.sourcekit-lsp/config.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/release/6.2/config.schema.json" +} \ No newline at end of file From b49c718b33be3f396e11e354a207910d50774f74 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 18 Oct 2025 22:55:06 +0100 Subject: [PATCH 2/4] Add invoke to Makefile --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index ff1a938..d47e660 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,9 @@ coverage: --instr-profile=$(SWIFT_BIN_PATH)/codecov/default.profdata \ --format=lcov > $(GITHUB_WORKSPACE)/lcov.info +local_invoke_demo_app: + curl -X POST 127.0.0.1:7000/invoke -H "Content-Type: application/json" -d @Tests/BreezeLambdaWebHookTests/Fixtures/get_webhook_api_gtw.json + preview_docc_lambda_api: swift package --disable-sandbox preview-documentation --target BreezeLambdaWebHook From a3f75ba3d7226c12107d47337e4631c1d12222fe Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 18 Oct 2025 22:55:37 +0100 Subject: [PATCH 3/4] Remove BreezeLambdaWebHookService --- .../BreezeDemoHTTPApplication.swift | 6 +- .../BreezeLambdaWebHook.swift | 18 +-- .../BreezeLambdaWebHookHandler.swift | 4 - .../BreezeLambdaWebHookService.swift | 108 ------------------ .../BreezeLambdaWebHook/HandlerContext.swift | 59 ++++++++++ .../BreezeLambdaWebHookService.swift | 40 ++----- Tests/BreezeLambdaWebHookTests/Lambda.swift | 16 +-- 7 files changed, 89 insertions(+), 162 deletions(-) delete mode 100644 Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift create mode 100644 Sources/BreezeLambdaWebHook/HandlerContext.swift diff --git a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift index e1330be..df61a9b 100644 --- a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift +++ b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift @@ -29,10 +29,14 @@ struct DemoLambdaHandler: BreezeLambdaWebHookHandler, Sendable { self.handlerContext = handlerContext } + var httpClient: HTTPClient { + handlerContext.httpClient + } + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { context.logger.info("Received event: \(event)") let request = HTTPClientRequest(url: "https://example.com") - let response = try await handlerContext.httpClient.execute(request, timeout: .seconds(5)) + let response = try await httpClient.execute(request, timeout: .seconds(5)) let bytes = try await response.body.collect(upTo: 1024 * 1024) // 1 MB Buffer let body = String(buffer: bytes) context.logger.info("Response body: \(body)") diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift index 48c8143..ddc4878 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift @@ -53,18 +53,20 @@ public struct BreezeLambdaWebHook: Se /// handling any errors that may occur during the process. /// It gracefully shuts down the service on termination signals. public func run() async throws { + let handlerContext = HandlerContext(config: config) + let lambdaHandler = LambdaHandler(handlerContext: handlerContext) + let runtime = LambdaRuntime(body: lambdaHandler.handle) + + let serviceGroup = ServiceGroup( + services: [handlerContext, runtime], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: config.logger + ) do { - let lambdaService = BreezeLambdaWebHookService( - config: config - ) - let serviceGroup = ServiceGroup( - services: [lambdaService], - gracefulShutdownSignals: [.sigterm, .sigint], - logger: config.logger - ) config.logger.error("Starting \(name) ...") try await serviceGroup.run() } catch { + try? handlerContext.syncShutdown() config.logger.error("Error running \(name): \(error.localizedDescription)") } } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift index 710930b..a71ae64 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift @@ -32,8 +32,4 @@ public extension BreezeLambdaWebHookHandler { var handler: String? { Lambda.env("_HANDLER") } - - var httpClient: AsyncHTTPClient.HTTPClient { - handlerContext.httpClient - } } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift deleted file mode 100644 index ff2e2c0..0000000 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import AsyncHTTPClient -import AWSLambdaEvents -import AWSLambdaRuntime -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -import ServiceLifecycle -import Logging - -public struct HandlerContext: Sendable { - public let httpClient: HTTPClient - - public init(httpClient: HTTPClient) { - self.httpClient = httpClient - } -} - -/// A service that runs a Breeze Lambda WebHook handler -/// -/// This service is responsible for providing the necessary context and configuration to the handler, -/// including the HTTP client and any other required resources. -/// -/// - Note: This service is designed to be used with the Breeze Lambda WebHook framework, which allows for handling webhooks in a serverless environment. -public actor BreezeLambdaWebHookService: Service { - - let config: BreezeHTTPClientConfig - var handlerContext: HandlerContext? - let httpClient: HTTPClient - - /// Initialilizer with a configuration for the Breeze HTTP Client. - public init(config: BreezeHTTPClientConfig) { - self.config = config - let timeout = HTTPClient.Configuration.Timeout( - connect: config.timeout, - read: config.timeout - ) - let configuration = HTTPClient.Configuration(timeout: timeout) - httpClient = HTTPClient( - eventLoopGroupProvider: .singleton, - configuration: configuration - ) - } - - /// Runs the Breeze Lambda WebHook service. - public func run() async throws { - let handlerContext = HandlerContext(httpClient: httpClient) - self.handlerContext = handlerContext - let runtime = LambdaRuntime(body: handler) - try await runTaskWithCancellationOnGracefulShutdown { - try await runtime.run() - } onGracefulShutdown: { - self.config.logger.info("Shutting down HTTP client...") - _ = self.httpClient.shutdown() - self.config.logger.info("HTTP client has been shut down.") - } - } - - /// Handler function that processes incoming events. - func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { - guard let handlerContext = handlerContext else { - throw BreezeClientServiceError.invalidHandler - } - return try await Handler(handlerContext: handlerContext).handle(event, context: context) - } - - /// Runs a task with cancellation on graceful shutdown. - /// - /// - Note: It's required to allow a full process shutdown without leaving tasks hanging. - private func runTaskWithCancellationOnGracefulShutdown( - operation: @escaping @Sendable () async throws -> Void, - onGracefulShutdown: () async throws -> Void - ) async throws { - let (cancelOrGracefulShutdown, cancelOrGracefulShutdownContinuation) = AsyncStream.makeStream() - let task = Task { - try await withTaskCancellationOrGracefulShutdownHandler { - try await operation() - } onCancelOrGracefulShutdown: { - cancelOrGracefulShutdownContinuation.yield() - cancelOrGracefulShutdownContinuation.finish() - } - } - for await _ in cancelOrGracefulShutdown { - try await onGracefulShutdown() - task.cancel() - } - } - - deinit { - _ = httpClient.shutdown() - } -} - diff --git a/Sources/BreezeLambdaWebHook/HandlerContext.swift b/Sources/BreezeLambdaWebHook/HandlerContext.swift new file mode 100644 index 0000000..3a696a9 --- /dev/null +++ b/Sources/BreezeLambdaWebHook/HandlerContext.swift @@ -0,0 +1,59 @@ +// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import AsyncHTTPClient +import AWSLambdaEvents +import AWSLambdaRuntime +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import ServiceLifecycle +import Logging + +public struct HandlerContext: Service { + public let httpClient: HTTPClient + private let config: BreezeHTTPClientConfig + + public init(config: BreezeHTTPClientConfig) { + let timeout = HTTPClient.Configuration.Timeout( + connect: config.timeout, + read: config.timeout + ) + let configuration = HTTPClient.Configuration(timeout: timeout) + self.httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: configuration + ) + self.config = config + } + + public func run() async throws { + config.logger.info("BreezeHTTPClientProvider started") + try await gracefulShutdown() + config.logger.info("BreezeHTTPClientProvider is gracefully shutting down ...") + try await onGracefulShutdown() + } + + public func onGracefulShutdown() async throws { + try await httpClient.shutdown() + config.logger.info("BreezeHTTPClientProvider: HTTPClient shutdown is completed.") + } + + public func syncShutdown() throws { + try httpClient.syncShutdown() + config.logger.info("BreezeHTTPClientProvider: HTTPClient syncShutdown is completed.") + } +} diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift index a3b3c84..3531e69 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -33,14 +33,6 @@ struct BreezeLambdaWebHookServiceTests { let decoder = JSONDecoder() - @Test("HandlerContext initializes with provided HTTP client") - func handlerContextInitializesWithClient() throws { - let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) - defer { try? httpClient.syncShutdown() } - let context = HandlerContext(httpClient: httpClient) - #expect(context.httpClient === httpClient) - } - @Test("Service creates HTTP client with correct timeout configuration") func serviceCreatesHTTPClientWithCorrectConfig() async throws { try await testGracefulShutdown { gracefulShutdownTestTrigger in @@ -48,7 +40,9 @@ struct BreezeLambdaWebHookServiceTests { try await withThrowingTaskGroup(of: Void.self) { group in let logger = Logger(label: "test") let config = BreezeHTTPClientConfig(timeout: .seconds(30), logger: logger) - let sut = BreezeLambdaWebHookService(config: config) + let handlerContext = HandlerContext(config: config) + let lambdaHandler = MockHandler(handlerContext: handlerContext) + let sut = LambdaRuntime(body: lambdaHandler.handle) group.addTask { try await Task.sleep(nanoseconds: 1_000_000_000) gracefulShutdownTestTrigger.triggerGracefulShutdown() @@ -60,12 +54,12 @@ struct BreezeLambdaWebHookServiceTests { } onGracefulShutdown: { logger.info("On Graceful Shutdown") continuation.yield() - continuation.finish() } } for await _ in gracefulStream { + continuation.finish() + try handlerContext.syncShutdown() logger.info("Graceful shutdown stream received") - let handlerContext = try #require(await sut.handlerContext) #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) group.cancelAll() @@ -74,21 +68,6 @@ struct BreezeLambdaWebHookServiceTests { } } - @Test("Handler throws when handlerContext is nil") - func handlerThrowsWhenContextIsNil() async throws { - let logger = Logger(label: "test") - let config = BreezeHTTPClientConfig(timeout: .seconds(30), logger: logger) - let service = BreezeLambdaWebHookService(config: config) - - let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") - let event = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let context = LambdaContext(requestID: "req1", traceID: "trace1", invokedFunctionARN: "", deadline: LambdaClock().now, logger: logger) - - await #expect(throws: BreezeClientServiceError.invalidHandler) { - try await service.handler(event: event, context: context) - } - } - @Test("Handler delegates to specific handler implementation") func handlerDelegatesToImplementation() async throws { try await testGracefulShutdown { gracefulShutdownTestTrigger in @@ -96,7 +75,9 @@ struct BreezeLambdaWebHookServiceTests { try await withThrowingTaskGroup(of: Void.self) { group in let logger = Logger(label: "test") let config = BreezeHTTPClientConfig(timeout: .seconds(30), logger: logger) - let sut = BreezeLambdaWebHookService(config: config) + let handlerContext = HandlerContext(config: config) + let lambdaHandler = MockHandler(handlerContext: handlerContext) + let sut = LambdaRuntime(body: lambdaHandler.handle) group.addTask { try await Task.sleep(nanoseconds: 1_000_000_000) gracefulShutdownTestTrigger.triggerGracefulShutdown() @@ -116,14 +97,13 @@ struct BreezeLambdaWebHookServiceTests { let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") let event = try decoder.decode(APIGatewayV2Request.self, from: createRequest) let context = LambdaContext(requestID: "req1", traceID: "trace1", invokedFunctionARN: "", deadline: LambdaClock().now, logger: logger) - - let response = try await sut.handler(event: event, context: context) - let handlerContext = try #require(await sut.handlerContext) + let response = try await lambdaHandler.handle(event, context: context) #expect(response.statusCode == 200) #expect(response.body == "Mock response") #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) group.cancelAll() + try handlerContext.syncShutdown() } } } diff --git a/Tests/BreezeLambdaWebHookTests/Lambda.swift b/Tests/BreezeLambdaWebHookTests/Lambda.swift index 1b67054..32eb0d5 100644 --- a/Tests/BreezeLambdaWebHookTests/Lambda.swift +++ b/Tests/BreezeLambdaWebHookTests/Lambda.swift @@ -34,17 +34,12 @@ extension Lambda { let logger = Logger(label: "evaluateHandler") let decoder = JSONDecoder() let encoder = JSONEncoder() - let timeout = HTTPClient.Configuration.Timeout( - connect: config.timeout, - read: config.timeout - ) - let configuration = HTTPClient.Configuration(timeout: timeout) - let httpClient = HTTPClient( - eventLoopGroupProvider: .singleton, - configuration: configuration - ) + let handlerContext = HandlerContext(config: config) + defer { + try? handlerContext.syncShutdown() + } let sut = handlerType.init( - handlerContext: HandlerContext(httpClient: httpClient) + handlerContext: handlerContext ) let closureHandler = ClosureHandler { event, context in //Inject Mock Response @@ -69,7 +64,6 @@ extension Lambda { try await handler.handle(event, responseWriter: writer, context: context) let result = await writer.output ?? ByteBuffer() let value = Data(result.readableBytesView) - try await httpClient.shutdown() return try decoder.decode(APIGatewayV2Response.self, from: value) } } From ade5ca1aced3472f64499fb8424808961e8394a1 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 19 Oct 2025 15:52:57 +0100 Subject: [PATCH 4/4] Improve Unit Tests --- .../BreezeLambdaWebHook/HandlerContext.swift | 18 +++ .../BreezeLambdaWebHookService.swift | 126 ------------------ .../BreezeLambdaWebHookTests.swift | 120 ++++++++--------- .../BreezeLambdaWebPostGetTests.swift | 91 +++++++++++++ .../HandlerContextTests.swift | 100 ++++++++++++++ 5 files changed, 266 insertions(+), 189 deletions(-) delete mode 100644 Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift create mode 100644 Tests/BreezeLambdaWebHookTests/BreezeLambdaWebPostGetTests.swift create mode 100644 Tests/BreezeLambdaWebHookTests/HandlerContextTests.swift diff --git a/Sources/BreezeLambdaWebHook/HandlerContext.swift b/Sources/BreezeLambdaWebHook/HandlerContext.swift index 3a696a9..47fb786 100644 --- a/Sources/BreezeLambdaWebHook/HandlerContext.swift +++ b/Sources/BreezeLambdaWebHook/HandlerContext.swift @@ -23,10 +23,25 @@ import Foundation import ServiceLifecycle import Logging +/// +/// `HandlerContext` provides a context for Lambda handlers, encapsulating an HTTP client and configuration. +/// +/// This struct is responsible for managing the lifecycle of the HTTP client used for outbound requests, +/// including graceful and synchronous shutdown procedures. It also provides logging for lifecycle events. +/// +/// - Parameters: +/// - httpClient: The HTTP client used for making outbound HTTP requests. +/// - config: The configuration for the HTTP client, including timeout and logger. +/// +/// - Conforms to: `Service` public struct HandlerContext: Service { + /// The HTTP client used for outbound requests. public let httpClient: HTTPClient + /// The configuration for the HTTP client. private let config: BreezeHTTPClientConfig + /// Initializes a new `HandlerContext` with the provided configuration. + /// - Parameter config: The configuration for the HTTP client. public init(config: BreezeHTTPClientConfig) { let timeout = HTTPClient.Configuration.Timeout( connect: config.timeout, @@ -40,6 +55,7 @@ public struct HandlerContext: Service { self.config = config } + /// Runs the `HandlerContext` and waits for a graceful shutdown. public func run() async throws { config.logger.info("BreezeHTTPClientProvider started") try await gracefulShutdown() @@ -47,11 +63,13 @@ public struct HandlerContext: Service { try await onGracefulShutdown() } + /// Handles graceful shutdown of the HTTP client. public func onGracefulShutdown() async throws { try await httpClient.shutdown() config.logger.info("BreezeHTTPClientProvider: HTTPClient shutdown is completed.") } + /// Synchronously shuts down the HTTP client. public func syncShutdown() throws { try httpClient.syncShutdown() config.logger.info("BreezeHTTPClientProvider: HTTPClient syncShutdown is completed.") diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift deleted file mode 100644 index 3531e69..0000000 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2024 (c) Andrea Scuderi - https://github.com/swift-serverless -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import Testing -@testable import AsyncHTTPClient -import AWSLambdaEvents -@testable import AWSLambdaRuntime -@testable import ServiceLifecycle -import ServiceLifecycleTestKit -@testable import BreezeLambdaWebHook -import Logging -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -import NIOCore - - -@Suite(.serialized) -struct BreezeLambdaWebHookServiceTests { - - let decoder = JSONDecoder() - - @Test("Service creates HTTP client with correct timeout configuration") - func serviceCreatesHTTPClientWithCorrectConfig() async throws { - try await testGracefulShutdown { gracefulShutdownTestTrigger in - let (gracefulStream, continuation) = AsyncStream.makeStream() - try await withThrowingTaskGroup(of: Void.self) { group in - let logger = Logger(label: "test") - let config = BreezeHTTPClientConfig(timeout: .seconds(30), logger: logger) - let handlerContext = HandlerContext(config: config) - let lambdaHandler = MockHandler(handlerContext: handlerContext) - let sut = LambdaRuntime(body: lambdaHandler.handle) - group.addTask { - try await Task.sleep(nanoseconds: 1_000_000_000) - gracefulShutdownTestTrigger.triggerGracefulShutdown() - } - group.addTask { - try await withGracefulShutdownHandler { - try await sut.run() - print("BreezeLambdaAPIService started successfully") - } onGracefulShutdown: { - logger.info("On Graceful Shutdown") - continuation.yield() - } - } - for await _ in gracefulStream { - continuation.finish() - try handlerContext.syncShutdown() - logger.info("Graceful shutdown stream received") - #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) - #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) - group.cancelAll() - } - } - } - } - - @Test("Handler delegates to specific handler implementation") - func handlerDelegatesToImplementation() async throws { - try await testGracefulShutdown { gracefulShutdownTestTrigger in - let (gracefulStream, continuation) = AsyncStream.makeStream() - try await withThrowingTaskGroup(of: Void.self) { group in - let logger = Logger(label: "test") - let config = BreezeHTTPClientConfig(timeout: .seconds(30), logger: logger) - let handlerContext = HandlerContext(config: config) - let lambdaHandler = MockHandler(handlerContext: handlerContext) - let sut = LambdaRuntime(body: lambdaHandler.handle) - group.addTask { - try await Task.sleep(nanoseconds: 1_000_000_000) - gracefulShutdownTestTrigger.triggerGracefulShutdown() - } - group.addTask { - try await withGracefulShutdownHandler { - try await sut.run() - print("BreezeLambdaAPIService started successfully") - } onGracefulShutdown: { - logger.info("On Graceful Shutdown") - continuation.yield() - continuation.finish() - } - } - for await _ in gracefulStream { - logger.info("Graceful shutdown stream received") - let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") - let event = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let context = LambdaContext(requestID: "req1", traceID: "trace1", invokedFunctionARN: "", deadline: LambdaClock().now, logger: logger) - let response = try await lambdaHandler.handle(event, context: context) - #expect(response.statusCode == 200) - #expect(response.body == "Mock response") - #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) - #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) - group.cancelAll() - try handlerContext.syncShutdown() - } - } - } - } -} - -struct MockHandler: BreezeLambdaWebHookHandler { - let handlerContext: HandlerContext - - init(handlerContext: HandlerContext) { - self.handlerContext = handlerContext - } - - func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { - return APIGatewayV2Response( - statusCode: .ok, - body: "Mock response" - ) - } -} diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift index 9cea1db..c68a348 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift @@ -1,4 +1,4 @@ -// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless +// Copyright 2024 (c) Andrea Scuderi - https://github.com/swift-serverless // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -13,79 +13,73 @@ // limitations under the License. import Testing +@testable import AsyncHTTPClient import AWSLambdaEvents -import AWSLambdaRuntime -import AsyncHTTPClient +@testable import AWSLambdaRuntime +@testable import ServiceLifecycle +import ServiceLifecycleTestKit @testable import BreezeLambdaWebHook import Logging +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif +import NIOCore -@Suite("BreezeLambdaWebHookSuite") -struct BreezeLambdaWebHookTests: ~Copyable { - let decoder = JSONDecoder() - let config = BreezeHTTPClientConfig( - timeout: .seconds(1), - logger: Logger(label: "test") - ) - - init() { - setEnvironmentVar(name: "_HANDLER", value: "build/webhook", overwrite: true) - setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) - } - - deinit { - unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") - unsetenv("_HANDLER") - } +@Suite(.serialized) +struct BreezeLambdaWebHookTests { - @Test("PostWhenMissingBody_ThenError") - func postWhenMissingBody_thenError() async throws { - let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(MyPostWebHook.self, config: config, with: request) - let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - - #expect(apiResponse.statusCode == .badRequest) - #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response.error == "invalidRequest") - } + let decoder = JSONDecoder() - @Test("PostWhenBody_ThenValue") - func postWhenBody_thenValue() async throws { - let createRequest = try Fixtures.fixture(name: Fixtures.postWebHook, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(MyPostWebHook.self, config: config, with: request) - let response: MyPostResponse = try apiResponse.decodeBody() - let body: MyPostRequest = try request.bodyObject() - - #expect(apiResponse.statusCode == .ok) - #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response.body == body.value) - #expect(response.handler == "build/webhook") + @Test("BreezeLambdaWebHook can be shutdown gracefully") + func breezeLambdaWebHookCanBeShutdownGracefully() async throws { + await testGracefulShutdown { gracefulShutdownTestTrigger in + let (gracefulStream, continuation) = AsyncStream.makeStream() + await withThrowingTaskGroup(of: Void.self) { group in + let logger = Logger(label: "test") + let config = BreezeHTTPClientConfig(timeout: .seconds(30), logger: logger) + let sut = BreezeLambdaWebHook.init(name: "Test", config: config) + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + group.addTask { + await withGracefulShutdownHandler { + do { + try await sut.run() + } catch { + Issue.record("Error running BreezeLambdaWebHook: \(error.localizedDescription)") + } + } onGracefulShutdown: { + logger.info("On Graceful Shutdown") + continuation.yield() + } + } + for await _ in gracefulStream { + #expect(sut.name == "Test") + #expect(sut.config.timeout == .seconds(30)) + continuation.finish() + logger.info("Graceful shutdown stream received") + group.cancelAll() + } + } + } } +} + +struct MockHandler: BreezeLambdaWebHookHandler { + let handlerContext: HandlerContext - @Test("GetWhenMissingQuery_ThenError") - func getWhenMissingQuery_thenError() async throws { - let createRequest = try Fixtures.fixture(name: Fixtures.postWebHook, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(MyGetWebHook.self, config: config, with: request) - let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - - #expect(apiResponse.statusCode == .badRequest) - #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response.error == "invalidRequest") + init(handlerContext: HandlerContext) { + self.handlerContext = handlerContext } - @Test("GetWhenQuery_ThenValue") - func getWhenQuery_thenValue() async throws { - let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(MyGetWebHook.self, config: config, with: request) - let response: [String: String] = try apiResponse.decodeBody() - - #expect(apiResponse.statusCode == .ok) - #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response.count == 2) + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + return APIGatewayV2Response( + statusCode: .ok, + body: "Mock response" + ) } } diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebPostGetTests.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebPostGetTests.swift new file mode 100644 index 0000000..fd84344 --- /dev/null +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebPostGetTests.swift @@ -0,0 +1,91 @@ +// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Testing +import AWSLambdaEvents +import AWSLambdaRuntime +import AsyncHTTPClient +@testable import BreezeLambdaWebHook +import Logging +import Foundation + +@Suite("BreezeLambdaWebPostGetTests") +struct BreezeLambdaWebPostGetTests: ~Copyable { + + let decoder = JSONDecoder() + let config = BreezeHTTPClientConfig( + timeout: .seconds(1), + logger: Logger(label: "test") + ) + + init() { + setEnvironmentVar(name: "_HANDLER", value: "build/webhook", overwrite: true) + setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) + } + + deinit { + unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") + unsetenv("_HANDLER") + } + + @Test("PostWhenMissingBody_ThenError") + func postWhenMissingBody_thenError() async throws { + let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") + let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyPostWebHook.self, config: config, with: request) + let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() + + #expect(apiResponse.statusCode == .badRequest) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + } + + @Test("PostWhenBody_ThenValue") + func postWhenBody_thenValue() async throws { + let createRequest = try Fixtures.fixture(name: Fixtures.postWebHook, type: "json") + let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyPostWebHook.self, config: config, with: request) + let response: MyPostResponse = try apiResponse.decodeBody() + let body: MyPostRequest = try request.bodyObject() + + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.body == body.value) + #expect(response.handler == "build/webhook") + } + + @Test("GetWhenMissingQuery_ThenError") + func getWhenMissingQuery_thenError() async throws { + let createRequest = try Fixtures.fixture(name: Fixtures.postWebHook, type: "json") + let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyGetWebHook.self, config: config, with: request) + let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() + + #expect(apiResponse.statusCode == .badRequest) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + } + + @Test("GetWhenQuery_ThenValue") + func getWhenQuery_thenValue() async throws { + let createRequest = try Fixtures.fixture(name: Fixtures.getWebHook, type: "json") + let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyGetWebHook.self, config: config, with: request) + let response: [String: String] = try apiResponse.decodeBody() + + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.count == 2) + } +} diff --git a/Tests/BreezeLambdaWebHookTests/HandlerContextTests.swift b/Tests/BreezeLambdaWebHookTests/HandlerContextTests.swift new file mode 100644 index 0000000..f2974c8 --- /dev/null +++ b/Tests/BreezeLambdaWebHookTests/HandlerContextTests.swift @@ -0,0 +1,100 @@ +// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Testing +import AWSLambdaEvents +import AWSLambdaRuntime +@testable import AsyncHTTPClient +@testable import BreezeLambdaWebHook +@testable import ServiceLifecycle +import ServiceLifecycleTestKit +import Logging +import Foundation + +@Suite("HandlerContextTests") +struct HandlerContextTests { + + @Test("HandlerContextInitialization") + func handlerContextInitializesWithConfig() throws { + let logger = Logger(label: "test") + let config = BreezeHTTPClientConfig(timeout: .seconds(1), logger: logger) + let context = HandlerContext(config: config) + #expect(context.httpClient.configuration.timeout.connect == .seconds(1)) + try context.syncShutdown() + } + + @Test("HandlerContextRun") + func handlerContextRunPerformsGracefulShutdown() async throws { + let logger = Logger(label: "test") + let config = BreezeHTTPClientConfig(timeout: .seconds(10), logger: logger) + let context = HandlerContext(config: config) + await testGracefulShutdown { gracefulShutdownTestTrigger in + let (gracefulStream, continuation) = AsyncStream.makeStream() + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + group.addTask { + try await withGracefulShutdownHandler { + try await context.run() + } onGracefulShutdown: { + logger.info("On Graceful Shutdown") + continuation.yield() + } + } + for await _ in gracefulStream { + continuation.finish() + logger.info("Graceful shutdown stream received") + #expect(context.httpClient.configuration.timeout.read == .seconds(10)) + #expect(context.httpClient.configuration.timeout.connect == .seconds(10)) + group.cancelAll() + } + } + } + } + + @Test("HandlerContextOnGracefulShutdown") + func handlerContextOnGracefulShutdownShutsDownHTTPClient() async throws { + let logger = Logger(label: "test") + let config = BreezeHTTPClientConfig(timeout: .seconds(1), logger: logger) + let context = HandlerContext(config: config) + try await context.onGracefulShutdown() + #expect(true) // If no error is thrown, shutdown completed successfully + } + + @Test("HandlerContextSyncShutdown") + func handlerContextSyncShutdownShutsDownHTTPClient() { + + let logger = Logger(label: "test") + let config = BreezeHTTPClientConfig(timeout: .seconds(1), logger: logger) + let context = HandlerContext(config: config) + do { + try context.syncShutdown() + } catch { + Issue.record("Expected syncShutdown to complete without errors, but got: \(error)") + } + } + + @Test("HandlerContextSyncShutdownThrowsAfterShutdown") + func handlerContextSyncShutdownThrowsAfterShutdown() { + let logger = Logger(label: "test") + let config = BreezeHTTPClientConfig(timeout: .seconds(1), logger: logger) + let context = HandlerContext(config: config) + try? context.syncShutdown() + #expect(throws: Error.self) { + try context.syncShutdown() + } + } +}