From 5a12994f5fa9b0d4f10acd4a82f2a3104115f657 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Mon, 5 May 2025 16:40:42 +0100 Subject: [PATCH 01/11] Fix WebHook with Swift6 --- Package.swift | 50 +++++++++--- .../BreezeDemoHTTPApplication.swift | 54 +++++++++++++ .../BreezeClientServiceConfig.swift | 33 ++++++++ .../BreezeHTTPClientService.swift | 56 ++++++++++++++ .../BreezeLambdaWebHook.swift | 76 ------------------- .../APIGatewayV2Request+Extensions.swift | 0 .../APIGatewayV2Response+Extensions.swift | 13 ++-- .../BreezeLambdaWebHookError.swift | 0 .../BreezeLambdaWebHookHandler.swift | 5 +- .../BreezeLambdaWebHookService.swift | 54 +++++++++++++ .../BreezeLambdaWebHookTests.swift | 3 +- Tests/BreezeLambdaWebHookTests/Lambda.swift | 8 +- .../MyGetWebHook.swift | 6 +- .../MyPostWebHook.swift | 6 +- docker/Dockerfile | 2 +- 15 files changed, 255 insertions(+), 111 deletions(-) create mode 100644 Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift create mode 100644 Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift create mode 100644 Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift delete mode 100644 Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift rename Sources/{BreezeLambdaWebHook => BreezeLambdaWebHookService}/APIGatewayV2Request+Extensions.swift (100%) rename Sources/{BreezeLambdaWebHook => BreezeLambdaWebHookService}/APIGatewayV2Response+Extensions.swift (84%) rename Sources/{BreezeLambdaWebHook => BreezeLambdaWebHookService}/BreezeLambdaWebHookError.swift (100%) rename Sources/{BreezeLambdaWebHook => BreezeLambdaWebHookService}/BreezeLambdaWebHookHandler.swift (84%) create mode 100644 Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift diff --git a/Package.swift b/Package.swift index df0851e..e947f3e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,39 +1,65 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +#if os(macOS) +let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15), .iOS(.v13)] +#else +let platforms: [PackageDescription.SupportedPlatform]? = nil +#endif + let package = Package( name: "BreezeLambdaWebHook", - platforms: [ - .macOS(.v13), - .iOS(.v15) - ], + platforms: platforms, products: [ .library( - name: "BreezeLambdaWebHook", - targets: ["BreezeLambdaWebHook"] + name: "BreezeLambdaWebHookService", + targets: ["BreezeLambdaWebHookService"] + ), + .library( + name: "BreezeHTTPClientService", + targets: ["BreezeHTTPClientService"] + ), + .executable( + name: "BreezeDemoHTTPApplication", + targets: ["BreezeDemoHTTPApplication"] ) ], dependencies: [ - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.2"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.1.0"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.5.0"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.11.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), ], targets: [ .target( - name: "BreezeLambdaWebHook", + name: "BreezeLambdaWebHookService", dependencies: [ + "BreezeHTTPClientService", .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), + .target( + name: "BreezeHTTPClientService", + dependencies: [ + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Logging", package: "swift-log") + ] + ), + .executableTarget( + name: "BreezeDemoHTTPApplication", + dependencies: [ + "BreezeLambdaWebHookService" + ] + ), .testTarget( name: "BreezeLambdaWebHookTests", dependencies: [ - .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), - "BreezeLambdaWebHook" + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + "BreezeLambdaWebHookService" ], resources: [.copy("Fixtures")] ), diff --git a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift new file mode 100644 index 0000000..1f87658 --- /dev/null +++ b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift @@ -0,0 +1,54 @@ +// 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 BreezeLambdaWebHookService +import BreezeHTTPClientService +import AWSLambdaEvents +import AWSLambdaRuntime +import Logging + +struct DemoLambdaHandler: BreezeLambdaWebHookHandler, Sendable { + var handlerContext: HandlerContext + + init(handlerContext: HandlerContext) { + self.handlerContext = handlerContext + } + + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + context.logger.info("Received event: \(event)") + return APIGatewayV2Response(with: "Hello World", statusCode: .ok) + } +} + +@main +struct BreezeDemoApplication { + static func main() async throws { + do { + let logger = Logger(label: "BreezeDemoApplication") + let httpClientService = BreezeHTTPClientService( + timeout: .seconds(60), + logger: logger + ) + let lambdaService = BreezeLambdaWebHookService.init( + serviceConfig: BreezeClientServiceConfig( + httpClientService: httpClientService, + logger: logger + ) + ) + try await lambdaService.run() + } catch { + print(error.localizedDescription) + } + } +} diff --git a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift new file mode 100644 index 0000000..2426f02 --- /dev/null +++ b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift @@ -0,0 +1,33 @@ +// 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 Logging + +public enum BreezeClientServiceError: Error { + case invalidHttpClient +} + +public struct BreezeClientServiceConfig: Sendable { + + public let httpClientService: BreezeHTTPClientServing + public let logger: Logger + + public init( + httpClientService: BreezeHTTPClientServing, + logger: Logger + ) { + self.httpClientService = httpClientService + self.logger = logger + } +} diff --git a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift new file mode 100644 index 0000000..fca1abc --- /dev/null +++ b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift @@ -0,0 +1,56 @@ +// 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 ServiceLifecycle +import AsyncHTTPClient +import NIOCore +import Logging + +public protocol BreezeHTTPClientServing: Actor, Service { + var httpClient: HTTPClient { get } +} + +public actor BreezeHTTPClientService: BreezeHTTPClientServing { + + public let httpClient: HTTPClient + let logger: Logger + + public init(timeout: TimeAmount, logger: Logger) { + self.logger = logger + let timeout = HTTPClient.Configuration.Timeout( + connect: timeout, + read: timeout + ) + let configuration = HTTPClient.Configuration(timeout: timeout) + self.httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: configuration + ) + logger.info("HTTPClientService config:") + logger.info("timeout \(timeout)") + } + + public func run() async throws { + logger.info("HTTPClientService started...") + try await gracefulShutdown() + + logger.info("Stopping HTTPClientService...") + try await httpClient.shutdown() + logger.info("HTTPClientService shutdown completed.") + } + + deinit { + try? httpClient.syncShutdown() + } +} diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift deleted file mode 100644 index 4f5f668..0000000 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift +++ /dev/null @@ -1,76 +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 -import AWSLambdaRuntimeCore -import Foundation - -public extension LambdaInitializationContext { - enum WebHook { - public static var timeout: Int64 = 30 - } -} - -public struct HandlerContext { - public let handler: String? - public let httpClient: HTTPClient -} - -public class BreezeLambdaWebHook: LambdaHandler { - public typealias Event = APIGatewayV2Request - public typealias Output = APIGatewayV2Response - - let handlerContext: HandlerContext - - public required init(context: LambdaInitializationContext) async throws { - let handler = Lambda.env("_HANDLER") - context.logger.info("handler: \(handler ?? "")") - - let timeout = HTTPClient.Configuration.Timeout( - connect: .seconds(LambdaInitializationContext.WebHook.timeout), - read: .seconds(LambdaInitializationContext.WebHook.timeout) - ) - - let configuration = HTTPClient.Configuration(timeout: timeout) - let httpClient = HTTPClient( - eventLoopGroupProvider: .shared(context.eventLoop), - configuration: configuration - ) - - handlerContext = HandlerContext(handler: handler, httpClient: httpClient) - - context.terminator.register(name: "shutdown") { eventLoop in - context.logger.info("shutdown: started") - let promise = eventLoop.makePromise(of: Void.self) - Task { - do { - try await self.handlerContext.httpClient.shutdown() - promise.succeed() - context.logger.info("shutdown: succeed") - } catch { - promise.fail(error) - context.logger.info("shutdown: fail") - } - } - return promise.futureResult - } - } - - public func handle(_ event: AWSLambdaEvents.APIGatewayV2Request, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> AWSLambdaEvents.APIGatewayV2Response { - return await Handler(handlerContext: handlerContext).handle(context: context, event: event) - } -} - diff --git a/Sources/BreezeLambdaWebHook/APIGatewayV2Request+Extensions.swift b/Sources/BreezeLambdaWebHookService/APIGatewayV2Request+Extensions.swift similarity index 100% rename from Sources/BreezeLambdaWebHook/APIGatewayV2Request+Extensions.swift rename to Sources/BreezeLambdaWebHookService/APIGatewayV2Request+Extensions.swift diff --git a/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaWebHookService/APIGatewayV2Response+Extensions.swift similarity index 84% rename from Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift rename to Sources/BreezeLambdaWebHookService/APIGatewayV2Response+Extensions.swift index 237f8aa..c73bb42 100644 --- a/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift +++ b/Sources/BreezeLambdaWebHookService/APIGatewayV2Response+Extensions.swift @@ -18,10 +18,6 @@ import class Foundation.JSONEncoder public extension APIGatewayV2Response { private static let encoder = JSONEncoder() - - /// defaultHeaders - /// Override the headers in APIGatewayV2Response - static var defaultHeaders = [ "Content-Type": "application/json" ] struct BodyError: Codable { public let error: String @@ -40,14 +36,19 @@ public extension APIGatewayV2Response { /// - Parameters: /// - object: Encodable Object /// - statusCode: HTTP Status Code - init(with object: Output, statusCode: HTTPResponse.Status) { + /// - headers: HTTP Headers + init( + with object: Output, + statusCode: HTTPResponse.Status, + headers: [String: String] = [ "Content-Type": "application/json" ] + ) { var body = "{}" if let data = try? Self.encoder.encode(object) { body = String(data: data, encoding: .utf8) ?? body } self.init( statusCode: statusCode, - headers: APIGatewayV2Response.defaultHeaders, + headers: headers, body: body, isBase64Encoded: false ) diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookError.swift similarity index 100% rename from Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift rename to Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookError.swift diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift similarity index 84% rename from Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift rename to Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift index 982c9a3..63f707b 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift +++ b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift @@ -15,13 +15,12 @@ import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime -import AWSLambdaRuntimeCore import Foundation -public protocol BreezeLambdaWebHookHandler { +public protocol BreezeLambdaWebHookHandler: LambdaHandler { var handlerContext: HandlerContext { get } init(handlerContext: HandlerContext) - func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response } public extension BreezeLambdaWebHookHandler { diff --git a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift new file mode 100644 index 0000000..058e3fb --- /dev/null +++ b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift @@ -0,0 +1,54 @@ +// 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 +import Foundation +import ServiceLifecycle +import BreezeHTTPClientService +import Logging + +public struct HandlerContext: Sendable { + public let handler: String? + public let httpClient: HTTPClient +} + +public actor BreezeLambdaWebHookService: Service { + + private let serviceConfig: BreezeClientServiceConfig + private var handlerContext: HandlerContext? + + public init(serviceConfig: BreezeClientServiceConfig) { + self.serviceConfig = serviceConfig + } + + public func run() async throws { + let _handler = Lambda.env("_HANDLER") + serviceConfig.logger.info("handler: \(_handler ?? "")") + let httpClient = await serviceConfig.httpClientService.httpClient + let handlerContext = HandlerContext(handler: _handler, httpClient: httpClient) + self.handlerContext = handlerContext + let runtime = LambdaRuntime(body: handler) + try await runtime.run() + } + + func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + guard let handlerContext = handlerContext else { + throw BreezeClientServiceError.invalidHttpClient + } + return try await Handler(handlerContext: handlerContext).handle(event, context: context) + } +} + diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift index 8fec216..8bee053 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift @@ -14,9 +14,8 @@ import AWSLambdaEvents import AWSLambdaRuntime -import AWSLambdaTesting import AsyncHTTPClient -@testable import BreezeLambdaWebHook +@testable import BreezeLambdaWebHookService import XCTest final class BreezeLambdaWebHookTests: XCTestCase { diff --git a/Tests/BreezeLambdaWebHookTests/Lambda.swift b/Tests/BreezeLambdaWebHookTests/Lambda.swift index 8ec17f5..09feef9 100644 --- a/Tests/BreezeLambdaWebHookTests/Lambda.swift +++ b/Tests/BreezeLambdaWebHookTests/Lambda.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. @@ -14,16 +14,14 @@ import AWSLambdaEvents import AWSLambdaRuntime -@testable import AWSLambdaRuntimeCore -import AWSLambdaTesting +@testable import AWSLambdaRuntime import Logging import NIO extension Lambda { public static func test( _ handlerType: Handler.Type, - with event: Handler.Event, - using config: TestConfig = .init() + with event: Handler.Event ) async throws -> Handler.Output { let logger = Logger(label: "test") let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) diff --git a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift index a685fda..5c5bebd 100644 --- a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift @@ -13,10 +13,10 @@ // limitations under the License. import Foundation -import BreezeLambdaWebHook +import BreezeLambdaWebHookService import AsyncHTTPClient import AWSLambdaEvents -import AWSLambdaRuntimeCore +import AWSLambdaRuntime class MyGetWebHook: BreezeLambdaWebHookHandler { @@ -26,7 +26,7 @@ class MyGetWebHook: BreezeLambdaWebHookHandler { self.handlerContext = handlerContext } - func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: AWSLambdaEvents.APIGatewayV2Request) async -> AWSLambdaEvents.APIGatewayV2Response { + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async -> APIGatewayV2Response { do { try await Task.sleep(nanoseconds: 1_000_000) guard let params = event.queryStringParameters else { diff --git a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift index 58857d6..b0a1ec7 100644 --- a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift @@ -13,10 +13,10 @@ // limitations under the License. import Foundation -import BreezeLambdaWebHook +import BreezeLambdaWebHookService import AsyncHTTPClient import AWSLambdaEvents -import AWSLambdaRuntimeCore +import AWSLambdaRuntime struct MyPostResponse: Codable { let handler: String? @@ -35,7 +35,7 @@ class MyPostWebHook: BreezeLambdaWebHookHandler { self.handlerContext = handlerContext } - func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: AWSLambdaEvents.APIGatewayV2Request) async -> AWSLambdaEvents.APIGatewayV2Response { + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async -> APIGatewayV2Response { do { try await Task.sleep(nanoseconds: 1_000_000) guard let body: MyPostRequest = try event.bodyObject() else { diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b0e9cb..ba01dcd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:5.7.3-amazonlinux2 as builder +FROM swift:6.1.0-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make From 8b28a0389118d845172e867cef8f9e906b7be1e2 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Tue, 6 May 2025 20:26:59 +0100 Subject: [PATCH 02/11] Fix Unit Tests --- .../BreezeDemoHTTPApplication.swift | 8 +- .../BreezeClientServiceConfig.swift | 7 +- .../BreezeLambdaWebHookHandler.swift | 2 +- .../BreezeLambdaWebHookService.swift | 9 +- .../BreezeLambdaWebHookTests.swift | 20 +++-- Tests/BreezeLambdaWebHookTests/Lambda.swift | 83 +++++++++++++------ 6 files changed, 80 insertions(+), 49 deletions(-) diff --git a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift index 1f87658..11a2ce9 100644 --- a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift +++ b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift @@ -40,11 +40,9 @@ struct BreezeDemoApplication { timeout: .seconds(60), logger: logger ) - let lambdaService = BreezeLambdaWebHookService.init( - serviceConfig: BreezeClientServiceConfig( - httpClientService: httpClientService, - logger: logger - ) + let serviceConfig = BreezeClientServiceConfig(httpClientService: httpClientService) + let lambdaService = BreezeLambdaWebHookService( + serviceConfig: serviceConfig ) try await lambdaService.run() } catch { diff --git a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift index 2426f02..10141f2 100644 --- a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift +++ b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift @@ -21,13 +21,8 @@ public enum BreezeClientServiceError: Error { public struct BreezeClientServiceConfig: Sendable { public let httpClientService: BreezeHTTPClientServing - public let logger: Logger - public init( - httpClientService: BreezeHTTPClientServing, - logger: Logger - ) { + public init(httpClientService: BreezeHTTPClientServing) { self.httpClientService = httpClientService - self.logger = logger } } diff --git a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift index 63f707b..35a610b 100644 --- a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift +++ b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift @@ -25,7 +25,7 @@ public protocol BreezeLambdaWebHookHandler: LambdaHandler { public extension BreezeLambdaWebHookHandler { var handler: String? { - handlerContext.handler + Lambda.env("_HANDLER") } var httpClient: AsyncHTTPClient.HTTPClient { handlerContext.httpClient diff --git a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift index 058e3fb..d23adb6 100644 --- a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift +++ b/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift @@ -21,8 +21,11 @@ import BreezeHTTPClientService import Logging public struct HandlerContext: Sendable { - public let handler: String? public let httpClient: HTTPClient + + public init(httpClient: HTTPClient) { + self.httpClient = httpClient + } } public actor BreezeLambdaWebHookService: Service { @@ -35,10 +38,8 @@ public actor BreezeLambdaWebHookService: Se } public func run() async throws { - let _handler = Lambda.env("_HANDLER") - serviceConfig.logger.info("handler: \(_handler ?? "")") let httpClient = await serviceConfig.httpClientService.httpClient - let handlerContext = HandlerContext(handler: _handler, httpClient: httpClient) + let handlerContext = HandlerContext(httpClient: httpClient) self.handlerContext = handlerContext let runtime = LambdaRuntime(body: handler) try await runtime.run() diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift index 8bee053..387c9a7 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift @@ -15,23 +15,31 @@ import AWSLambdaEvents import AWSLambdaRuntime import AsyncHTTPClient +import BreezeHTTPClientService @testable import BreezeLambdaWebHookService import XCTest +import Logging final class BreezeLambdaWebHookTests: XCTestCase { let decoder = JSONDecoder() + let config = BreezeClientServiceConfig( + httpClientService: BreezeHTTPClientService( + timeout: .seconds(1), + logger: Logger(label: "test") + ) + ) override func setUpWithError() throws { try super.setUpWithError() setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) - LambdaInitializationContext.WebHook.timeout = 1 +// LambdaInitializationContext.WebHook.timeout = 1 } override func tearDownWithError() throws { unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") unsetenv("_HANDLER") - LambdaInitializationContext.WebHook.timeout = 30 +// LambdaInitializationContext.WebHook.timeout = 30 try super.tearDownWithError() } @@ -39,7 +47,7 @@ final class BreezeLambdaWebHookTests: XCTestCase { setEnvironmentVar(name: "_HANDLER", value: "build/webhook.get", overwrite: true) 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(BreezeLambdaWebHook.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyPostWebHook.self, config: config, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .badRequest) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -50,7 +58,7 @@ final class BreezeLambdaWebHookTests: XCTestCase { setEnvironmentVar(name: "_HANDLER", value: "build/webhook.post", overwrite: true) 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(BreezeLambdaWebHook.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyPostWebHook.self, config: config, with: request) let response: MyPostResponse = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .ok) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -63,7 +71,7 @@ final class BreezeLambdaWebHookTests: XCTestCase { setEnvironmentVar(name: "_HANDLER", value: "build/webhook.get", overwrite: true) 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(BreezeLambdaWebHook.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyGetWebHook.self, config: config, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .badRequest) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -74,7 +82,7 @@ final class BreezeLambdaWebHookTests: XCTestCase { setEnvironmentVar(name: "_HANDLER", value: "build/webhook.post", overwrite: true) 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(BreezeLambdaWebHook.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(MyGetWebHook.self, config: config, with: request) let response: [String: String] = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .ok) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) diff --git a/Tests/BreezeLambdaWebHookTests/Lambda.swift b/Tests/BreezeLambdaWebHookTests/Lambda.swift index 09feef9..3dbe2bd 100644 --- a/Tests/BreezeLambdaWebHookTests/Lambda.swift +++ b/Tests/BreezeLambdaWebHookTests/Lambda.swift @@ -13,41 +13,70 @@ // limitations under the License. import AWSLambdaEvents -import AWSLambdaRuntime +import BreezeLambdaWebHookService +import BreezeHTTPClientService +import Foundation @testable import AWSLambdaRuntime import Logging import NIO extension Lambda { - public static func test( - _ handlerType: Handler.Type, - with event: Handler.Event - ) async throws -> Handler.Output { - let logger = Logger(label: "test") - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - defer { - try! eventLoopGroup.syncShutdownGracefully() + public static func test( + _ handlerType: any BreezeLambdaWebHookHandler.Type, + config: BreezeClientServiceConfig, + with event: APIGatewayV2Request) async throws -> APIGatewayV2Response { + + let logger = Logger(label: "evaluateHandler") + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + let sut = await handlerType.init( + handlerContext: HandlerContext(httpClient: config.httpClientService.httpClient) + ) + let closureHandler = ClosureHandler { event, context in + //Inject Mock Response + try await sut.handle(event, context: context) } - let eventLoop = eventLoopGroup.next() - - let initContext = LambdaInitializationContext.__forTestsOnly( - logger: logger, - eventLoop: eventLoop + + var handler = LambdaCodableAdapter( + encoder: encoder, + decoder: decoder, + handler: LambdaHandlerAdapter(handler: closureHandler) ) - + let data = try encoder.encode(event) + let event = ByteBuffer(data: data) + let writer = MockLambdaResponseStreamWriter() let context = LambdaContext.__forTestsOnly( - requestID: config.requestID, - traceID: config.traceID, - invokedFunctionARN: config.invokedFunctionARN, - timeout: config.timeout, - logger: logger, - eventLoop: eventLoop + requestID: UUID().uuidString, + traceID: UUID().uuidString, + invokedFunctionARN: "arn:", + timeout: .milliseconds(6000), + logger: logger ) - let handler = try await Handler(context: initContext) - defer { - let eventLoop = initContext.eventLoop.next() - try? initContext.terminator.terminate(eventLoop: eventLoop).wait() - } - return try await handler.handle(event, context: context) + try await handler.handle(event, responseWriter: writer, context: context) + let result = await writer.output ?? ByteBuffer() + try await config.httpClientService.httpClient.shutdown() + return try decoder.decode(APIGatewayV2Response.self, from: result) } } + +final actor MockLambdaResponseStreamWriter: LambdaResponseStreamWriter { + private var buffer: ByteBuffer? + + var output: ByteBuffer? { + self.buffer + } + + func writeAndFinish(_ buffer: ByteBuffer) async throws { + self.buffer = buffer + } + + func write(_ buffer: ByteBuffer) async throws { + fatalError("Unexpected call") + } + + func finish() async throws { + fatalError("Unexpected call") + } +} + From a97f69b6dec53575bb976461b780fb0370686ef4 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 24 May 2025 19:08:50 +0100 Subject: [PATCH 03/11] Simplify Implementation - Graceful shutdown not working --- Package.swift | 26 ++-- .../BreezeDemoHTTPApplication.swift | 58 +++++--- .../BreezeHTTPClientService.swift | 56 -------- .../APIGatewayV2Request+Extensions.swift | 0 .../APIGatewayV2Response+Extensions.swift | 0 .../BreezeHTTPClientConfig.swift} | 14 +- .../BreezeLambdaWebHook.swift | 47 ++++++ .../BreezeLambdaWebHookError.swift | 0 .../BreezeLambdaWebHookHandler.swift | 0 .../BreezeLambdaWebHookService.swift | 31 +++- .../BreezeLambdaWebHookService.swift | 134 ++++++++++++++++++ .../BreezeLambdaWebHookTests.swift | 76 +++++----- Tests/BreezeLambdaWebHookTests/Lambda.swift | 88 ++++++------ .../MyGetWebHook.swift | 2 +- .../MyPostWebHook.swift | 2 +- 15 files changed, 349 insertions(+), 185 deletions(-) delete mode 100644 Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift rename Sources/{BreezeLambdaWebHookService => BreezeLambdaWebHook}/APIGatewayV2Request+Extensions.swift (100%) rename Sources/{BreezeLambdaWebHookService => BreezeLambdaWebHook}/APIGatewayV2Response+Extensions.swift (100%) rename Sources/{BreezeHTTPClientService/BreezeClientServiceConfig.swift => BreezeLambdaWebHook/BreezeHTTPClientConfig.swift} (75%) create mode 100644 Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift rename Sources/{BreezeLambdaWebHookService => BreezeLambdaWebHook}/BreezeLambdaWebHookError.swift (100%) rename Sources/{BreezeLambdaWebHookService => BreezeLambdaWebHook}/BreezeLambdaWebHookHandler.swift (100%) rename Sources/{BreezeLambdaWebHookService => BreezeLambdaWebHook}/BreezeLambdaWebHookService.swift (62%) create mode 100644 Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift diff --git a/Package.swift b/Package.swift index e947f3e..d3b1773 100644 --- a/Package.swift +++ b/Package.swift @@ -14,12 +14,8 @@ let package = Package( platforms: platforms, products: [ .library( - name: "BreezeLambdaWebHookService", - targets: ["BreezeLambdaWebHookService"] - ), - .library( - name: "BreezeHTTPClientService", - targets: ["BreezeHTTPClientService"] + name: "BreezeLambdaWebHook", + targets: ["BreezeLambdaWebHook"] ), .executable( name: "BreezeDemoHTTPApplication", @@ -28,38 +24,32 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), +// .package(path: "../swift-aws-lambda-runtime"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.5.0"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.11.2"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.3") ], targets: [ .target( - name: "BreezeLambdaWebHookService", + name: "BreezeLambdaWebHook", dependencies: [ - "BreezeHTTPClientService", .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), - .target( - name: "BreezeHTTPClientService", - dependencies: [ - .product(name: "AsyncHTTPClient", package: "async-http-client"), - .product(name: "Logging", package: "swift-log") - ] - ), .executableTarget( name: "BreezeDemoHTTPApplication", dependencies: [ - "BreezeLambdaWebHookService" + "BreezeLambdaWebHook" ] ), .testTarget( name: "BreezeLambdaWebHookTests", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - "BreezeLambdaWebHookService" + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), + "BreezeLambdaWebHook" ], resources: [.copy("Fixtures")] ), diff --git a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift index 11a2ce9..3755713 100644 --- a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift +++ b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift @@ -12,12 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import BreezeLambdaWebHookService -import BreezeHTTPClientService +import BreezeLambdaWebHook import AWSLambdaEvents import AWSLambdaRuntime +import AsyncHTTPClient import Logging +import NIOCore +import ServiceLifecycle +/// This is a simple example of a Breeze Lambda WebHook handler. +/// It uses the BreezeHTTPClientService to make an HTTP request to example.com +/// and returns the response body as a string. struct DemoLambdaHandler: BreezeLambdaWebHookHandler, Sendable { var handlerContext: HandlerContext @@ -27,26 +32,43 @@ struct DemoLambdaHandler: BreezeLambdaWebHookHandler, Sendable { func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { context.logger.info("Received event: \(event)") - return APIGatewayV2Response(with: "Hello World", statusCode: .ok) + let request = HTTPClientRequest(url: "https://example.com") + let response = try await handlerContext.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)") + return APIGatewayV2Response(with: body, statusCode: .ok) } } +/// This is the main entry point for the Breeze Lambda WebHook application. +/// It creates an instance of the BreezeHTTPApplication and runs it. +/// The application name is used for logging and metrics. +/// The timeout is used to set the maximum time allowed for the Lambda function to run. +/// The default timeout is 30 seconds, but it can be changed to any value. +/// +/// Local Testing: +/// +/// The application will listen for incoming HTTP requests on port 7000 when run locally. +/// +/// Use CURL to invoke the Lambda function, passing a JSON file containg API Gateway V2 request: +/// +/// `curl -X POST 127.0.0.1:7000/invoke -H "Content-Type: application/json" -d @Tests/BreezeLambdaWebHookTests/Fixtures/get_webhook_api_gtw.json` @main -struct BreezeDemoApplication { +struct BreezeDemoHTTPApplication { + + static let applicationName = "BreezeDemoHTTPApplication" + static let logger = Logger(label: "BreezeDemoHTTPApplication") + static func main() async throws { - do { - let logger = Logger(label: "BreezeDemoApplication") - let httpClientService = BreezeHTTPClientService( - timeout: .seconds(60), - logger: logger - ) - let serviceConfig = BreezeClientServiceConfig(httpClientService: httpClientService) - let lambdaService = BreezeLambdaWebHookService( - serviceConfig: serviceConfig - ) - try await lambdaService.run() - } catch { - print(error.localizedDescription) - } + let config = BreezeHTTPClientConfig( + timeout: .seconds(30), + logger: logger + ) + let app = BreezeLambdaWebHook( + name: applicationName, + config: config, + ) + try await app.run() } } diff --git a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift deleted file mode 100644 index fca1abc..0000000 --- a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift +++ /dev/null @@ -1,56 +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 ServiceLifecycle -import AsyncHTTPClient -import NIOCore -import Logging - -public protocol BreezeHTTPClientServing: Actor, Service { - var httpClient: HTTPClient { get } -} - -public actor BreezeHTTPClientService: BreezeHTTPClientServing { - - public let httpClient: HTTPClient - let logger: Logger - - public init(timeout: TimeAmount, logger: Logger) { - self.logger = logger - let timeout = HTTPClient.Configuration.Timeout( - connect: timeout, - read: timeout - ) - let configuration = HTTPClient.Configuration(timeout: timeout) - self.httpClient = HTTPClient( - eventLoopGroupProvider: .singleton, - configuration: configuration - ) - logger.info("HTTPClientService config:") - logger.info("timeout \(timeout)") - } - - public func run() async throws { - logger.info("HTTPClientService started...") - try await gracefulShutdown() - - logger.info("Stopping HTTPClientService...") - try await httpClient.shutdown() - logger.info("HTTPClientService shutdown completed.") - } - - deinit { - try? httpClient.syncShutdown() - } -} diff --git a/Sources/BreezeLambdaWebHookService/APIGatewayV2Request+Extensions.swift b/Sources/BreezeLambdaWebHook/APIGatewayV2Request+Extensions.swift similarity index 100% rename from Sources/BreezeLambdaWebHookService/APIGatewayV2Request+Extensions.swift rename to Sources/BreezeLambdaWebHook/APIGatewayV2Request+Extensions.swift diff --git a/Sources/BreezeLambdaWebHookService/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift similarity index 100% rename from Sources/BreezeLambdaWebHookService/APIGatewayV2Response+Extensions.swift rename to Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift diff --git a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift similarity index 75% rename from Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift rename to Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift index 10141f2..191fbad 100644 --- a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift +++ b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift @@ -13,16 +13,18 @@ // limitations under the License. import Logging +import NIOCore public enum BreezeClientServiceError: Error { case invalidHttpClient } -public struct BreezeClientServiceConfig: Sendable { - - public let httpClientService: BreezeHTTPClientServing - - public init(httpClientService: BreezeHTTPClientServing) { - self.httpClientService = httpClientService +public struct BreezeHTTPClientConfig: Sendable { + public init(timeout: TimeAmount, logger: Logger) { + self.timeout = timeout + self.logger = logger } + + public let timeout: TimeAmount + public let logger: Logger } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift new file mode 100644 index 0000000..1d46fc4 --- /dev/null +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift @@ -0,0 +1,47 @@ +// 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 AWSLambdaEvents +import AWSLambdaRuntime +import ServiceLifecycle +import Logging +import NIOCore + +public struct BreezeLambdaWebHook: Service { + + public let name: String + public let config: BreezeHTTPClientConfig + + public init(name: String, config: BreezeHTTPClientConfig) { + self.name = name + self.config = config + } + + public func run() async throws { + do { + let lambdaService = BreezeLambdaWebHookService( + config: config + ) + let serviceGroup = ServiceGroup( + services: [lambdaService], + gracefulShutdownSignals: [.sigterm], + logger: config.logger + ) + config.logger.error("Starting \(name) ...") + try await serviceGroup.run() + } catch { + config.logger.error("Error running \(name): \(error.localizedDescription)") + } + } +} diff --git a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookError.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift similarity index 100% rename from Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookError.swift rename to Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift diff --git a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift similarity index 100% rename from Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookHandler.swift rename to Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift diff --git a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift similarity index 62% rename from Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift rename to Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift index d23adb6..18dcbe1 100644 --- a/Sources/BreezeLambdaWebHookService/BreezeLambdaWebHookService.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift @@ -17,7 +17,6 @@ import AWSLambdaEvents import AWSLambdaRuntime import Foundation import ServiceLifecycle -import BreezeHTTPClientService import Logging public struct HandlerContext: Sendable { @@ -30,19 +29,37 @@ public struct HandlerContext: Sendable { public actor BreezeLambdaWebHookService: Service { - private let serviceConfig: BreezeClientServiceConfig - private var handlerContext: HandlerContext? + let config: BreezeHTTPClientConfig + var handlerContext: HandlerContext? - public init(serviceConfig: BreezeClientServiceConfig) { - self.serviceConfig = serviceConfig + public init(config: BreezeHTTPClientConfig) { + self.config = config } public func run() async throws { - let httpClient = await serviceConfig.httpClientService.httpClient + 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(httpClient: httpClient) self.handlerContext = handlerContext let runtime = LambdaRuntime(body: handler) - try await runtime.run() + try await withGracefulShutdownHandler { + try await runtime.run() + } onGracefulShutdown: { + do { + self.config.logger.info("Shutting down HTTP client...") + try httpClient.syncShutdown() + self.config.logger.info("HTTP client has been shut down.") + } catch { + self.config.logger.error("Error shutting down HTTP client: \(error)") + } + } } func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift new file mode 100644 index 0000000..7b4a676 --- /dev/null +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -0,0 +1,134 @@ +// 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 +import AWSLambdaRuntime +@testable import ServiceLifecycle +import ServiceLifecycleTestKit +@testable import BreezeLambdaWebHook +import Logging +import Foundation +import NIOCore + + +@Suite("BreezeLambdaWebHookServiceTests") +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 +// 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) +// +// await withTaskCancellationOrGracefulShutdownHandler { +// group.addTask { +// try await sut.run() +// } +// } onCancelOrGracefulShutdown: { +// gracefulShutdownTestTrigger.triggerGracefulShutdown() +// logger.info("On Graceful Shutdown") +// } +// +// try await Task.sleep(nanoseconds: 1_000_000_000) +// group.cancelAll() +// +// let handlerContext = try #require(await sut.handlerContext) +// #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) +// #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) +// } +// } +// } + + @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: .now(), logger: logger) + + await #expect(throws: BreezeClientServiceError.invalidHttpClient) { + try await service.handler(event: event, context: context) + } + } + +// @Test("Handler delegates to specific handler implementation") +// func handlerDelegatesToImplementation() async throws { +// try await testGracefulShutdown { gracefulShutdownTestTrigger in +// 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) +// +// group.addTask { +// try await withGracefulShutdownHandler { +// try await sut.run() +// } onGracefulShutdown: { +// logger.info("On Graceful Shutdown") +// } +// } +// group.addTask { +// try await Task.sleep(nanoseconds: 1_000_000_000) +// gracefulShutdownTestTrigger.triggerGracefulShutdown() +// } +// +// 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: .now(), logger: logger) +// +// let response = try await sut.handler(event: event, context: context) +// let handlerContext = try #require(await sut.handlerContext) +// #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() +// } +// } +// } +} + +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 387c9a7..9cea1db 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift @@ -12,80 +12,80 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Testing import AWSLambdaEvents import AWSLambdaRuntime import AsyncHTTPClient -import BreezeHTTPClientService -@testable import BreezeLambdaWebHookService -import XCTest +@testable import BreezeLambdaWebHook import Logging +import Foundation -final class BreezeLambdaWebHookTests: XCTestCase { +@Suite("BreezeLambdaWebHookSuite") +struct BreezeLambdaWebHookTests: ~Copyable { let decoder = JSONDecoder() - let config = BreezeClientServiceConfig( - httpClientService: BreezeHTTPClientService( - timeout: .seconds(1), - logger: Logger(label: "test") - ) + let config = BreezeHTTPClientConfig( + timeout: .seconds(1), + logger: Logger(label: "test") ) - - override func setUpWithError() throws { - try super.setUpWithError() + + init() { + setEnvironmentVar(name: "_HANDLER", value: "build/webhook", overwrite: true) setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) -// LambdaInitializationContext.WebHook.timeout = 1 } - - override func tearDownWithError() throws { + + deinit { unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") unsetenv("_HANDLER") -// LambdaInitializationContext.WebHook.timeout = 30 - try super.tearDownWithError() } - func test_postWhenMissingBody_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/webhook.get", overwrite: true) + @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() - XCTAssertEqual(apiResponse.statusCode, .badRequest) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + + #expect(apiResponse.statusCode == .badRequest) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") } - func test_postWhenBody_thenValue() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/webhook.post", overwrite: true) + @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() - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) let body: MyPostRequest = try request.bodyObject() - XCTAssertEqual(response.body, body.value) - XCTAssertEqual(response.handler, "build/webhook.post") + + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.body == body.value) + #expect(response.handler == "build/webhook") } - func test_getWhenMissingQuery_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/webhook.get", overwrite: true) + @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() - XCTAssertEqual(apiResponse.statusCode, .badRequest) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + + #expect(apiResponse.statusCode == .badRequest) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") } - func test_getWhenQuery_thenValue() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/webhook.post", overwrite: true) + @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() - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.count, 2) + + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.count == 2) } } diff --git a/Tests/BreezeLambdaWebHookTests/Lambda.swift b/Tests/BreezeLambdaWebHookTests/Lambda.swift index 3dbe2bd..180fcff 100644 --- a/Tests/BreezeLambdaWebHookTests/Lambda.swift +++ b/Tests/BreezeLambdaWebHookTests/Lambda.swift @@ -12,69 +12,77 @@ // See the License for the specific language governing permissions and // limitations under the License. -import AWSLambdaEvents -import BreezeLambdaWebHookService -import BreezeHTTPClientService import Foundation -@testable import AWSLambdaRuntime +import AsyncHTTPClient +import AWSLambdaEvents import Logging import NIO +@testable import BreezeLambdaWebHook +@testable import AWSLambdaRuntime + extension Lambda { public static func test( _ handlerType: any BreezeLambdaWebHookHandler.Type, - config: BreezeClientServiceConfig, + config: BreezeHTTPClientConfig, with event: APIGatewayV2Request) async throws -> APIGatewayV2Response { + 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 sut = handlerType.init( + handlerContext: HandlerContext(httpClient: httpClient) + ) + let closureHandler = ClosureHandler { event, context in + //Inject Mock Response + try await sut.handle(event, context: context) + } - let logger = Logger(label: "evaluateHandler") - let decoder = JSONDecoder() - let encoder = JSONEncoder() - - let sut = await handlerType.init( - handlerContext: HandlerContext(httpClient: config.httpClientService.httpClient) - ) - let closureHandler = ClosureHandler { event, context in - //Inject Mock Response - try await sut.handle(event, context: context) + var handler = LambdaCodableAdapter( + encoder: encoder, + decoder: decoder, + handler: LambdaHandlerAdapter(handler: closureHandler) + ) + let data = try encoder.encode(event) + let event = ByteBuffer(data: data) + let writer = MockLambdaResponseStreamWriter() + let context = LambdaContext.__forTestsOnly( + requestID: UUID().uuidString, + traceID: UUID().uuidString, + invokedFunctionARN: "arn:", + timeout: .milliseconds(6000), + logger: logger + ) + try await handler.handle(event, responseWriter: writer, context: context) + let result = await writer.output ?? ByteBuffer() + try await httpClient.shutdown() + return try decoder.decode(APIGatewayV2Response.self, from: result) } - - var handler = LambdaCodableAdapter( - encoder: encoder, - decoder: decoder, - handler: LambdaHandlerAdapter(handler: closureHandler) - ) - let data = try encoder.encode(event) - let event = ByteBuffer(data: data) - let writer = MockLambdaResponseStreamWriter() - let context = LambdaContext.__forTestsOnly( - requestID: UUID().uuidString, - traceID: UUID().uuidString, - invokedFunctionARN: "arn:", - timeout: .milliseconds(6000), - logger: logger - ) - try await handler.handle(event, responseWriter: writer, context: context) - let result = await writer.output ?? ByteBuffer() - try await config.httpClientService.httpClient.shutdown() - return try decoder.decode(APIGatewayV2Response.self, from: result) - } } final actor MockLambdaResponseStreamWriter: LambdaResponseStreamWriter { private var buffer: ByteBuffer? - + var output: ByteBuffer? { self.buffer } - + func writeAndFinish(_ buffer: ByteBuffer) async throws { self.buffer = buffer } - + func write(_ buffer: ByteBuffer) async throws { fatalError("Unexpected call") } - + func finish() async throws { fatalError("Unexpected call") } diff --git a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift index 5c5bebd..6f4eb89 100644 --- a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift @@ -13,7 +13,7 @@ // limitations under the License. import Foundation -import BreezeLambdaWebHookService +import BreezeLambdaWebHook import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime diff --git a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift index b0a1ec7..31d9a04 100644 --- a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift @@ -13,7 +13,7 @@ // limitations under the License. import Foundation -import BreezeLambdaWebHookService +import BreezeLambdaWebHook import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime From 91157ca50ac938c1c88ebf78969953757127c6e8 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 15:33:08 +0100 Subject: [PATCH 04/11] Fix Graceful Shutdown implementation --- Package.swift | 17 +-- .../BreezeLambdaWebHook.swift | 2 +- .../BreezeLambdaWebHookService.swift | 49 +++++-- .../BreezeLambdaWebHookService.swift | 125 +++++++++--------- 4 files changed, 106 insertions(+), 87 deletions(-) diff --git a/Package.swift b/Package.swift index d3b1773..a82bed7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,17 +1,13 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription -#if os(macOS) -let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15), .iOS(.v13)] -#else -let platforms: [PackageDescription.SupportedPlatform]? = nil -#endif - let package = Package( name: "BreezeLambdaWebHook", - platforms: platforms, + platforms: [ + .macOS(.v15) + ], products: [ .library( name: "BreezeLambdaWebHook", @@ -23,10 +19,9 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), -// .package(path: "../swift-aws-lambda-runtime"), + .package(url: "https://github.com/andrea-scuderi/swift-aws-lambda-runtime.git", branch: "main"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.5.0"), - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.11.2"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.22.0"), .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.3") ], targets: [ diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift index 1d46fc4..d3e1b1e 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift @@ -35,7 +35,7 @@ public struct BreezeLambdaWebHook: Se ) let serviceGroup = ServiceGroup( services: [lambdaService], - gracefulShutdownSignals: [.sigterm], + gracefulShutdownSignals: [.sigterm, .sigint], logger: config.logger ) config.logger.error("Starting \(name) ...") diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift index 18dcbe1..e487890 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift @@ -31,34 +31,33 @@ public actor BreezeLambdaWebHookService: Se let config: BreezeHTTPClientConfig var handlerContext: HandlerContext? + let httpClient: HTTPClient + private var isStarted = false public init(config: BreezeHTTPClientConfig) { self.config = config - } - - public func run() async throws { let timeout = HTTPClient.Configuration.Timeout( connect: config.timeout, read: config.timeout ) let configuration = HTTPClient.Configuration(timeout: timeout) - let httpClient = HTTPClient( + httpClient = HTTPClient( eventLoopGroupProvider: .singleton, configuration: configuration ) + } + + public func run() async throws { + isStarted = true let handlerContext = HandlerContext(httpClient: httpClient) self.handlerContext = handlerContext let runtime = LambdaRuntime(body: handler) - try await withGracefulShutdownHandler { + try await runTaskWithCancellationOnGracefulShutdown { try await runtime.run() } onGracefulShutdown: { - do { - self.config.logger.info("Shutting down HTTP client...") - try httpClient.syncShutdown() - self.config.logger.info("HTTP client has been shut down.") - } catch { - self.config.logger.error("Error shutting down HTTP client: \(error)") - } + self.config.logger.info("Shutting down HTTP client...") + _ = self.httpClient.shutdown() + self.config.logger.info("HTTP client has been shut down.") } } @@ -68,5 +67,31 @@ public actor BreezeLambdaWebHookService: Se } 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/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift index 7b4a676..88cf841 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -37,32 +37,32 @@ struct BreezeLambdaWebHookServiceTests { #expect(context.httpClient === httpClient) } -// @Test("Service creates HTTP client with correct timeout configuration") -// func serviceCreatesHTTPClientWithCorrectConfig() async throws { -// try await testGracefulShutdown { gracefulShutdownTestTrigger in -// 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) -// -// await withTaskCancellationOrGracefulShutdownHandler { -// group.addTask { -// try await sut.run() -// } -// } onCancelOrGracefulShutdown: { -// gracefulShutdownTestTrigger.triggerGracefulShutdown() -// logger.info("On Graceful Shutdown") -// } -// -// try await Task.sleep(nanoseconds: 1_000_000_000) -// group.cancelAll() -// -// let handlerContext = try #require(await sut.handlerContext) -// #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) -// #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) -// } -// } -// } + @Test("Service creates HTTP client with correct timeout configuration") + func serviceCreatesHTTPClientWithCorrectConfig() async throws { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + 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) + + await withTaskCancellationOrGracefulShutdownHandler { + group.addTask { + try await sut.run() + } + } onCancelOrGracefulShutdown: { + gracefulShutdownTestTrigger.triggerGracefulShutdown() + logger.info("On Graceful Shutdown") + } + + try await Task.sleep(nanoseconds: 1_000_000_000) + group.cancelAll() + + let handlerContext = try #require(await sut.handlerContext) + #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) + #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) + } + } + } @Test("Handler throws when handlerContext is nil") func handlerThrowsWhenContextIsNil() async throws { @@ -79,43 +79,42 @@ struct BreezeLambdaWebHookServiceTests { } } -// @Test("Handler delegates to specific handler implementation") -// func handlerDelegatesToImplementation() async throws { -// try await testGracefulShutdown { gracefulShutdownTestTrigger in -// 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) -// -// group.addTask { -// try await withGracefulShutdownHandler { -// try await sut.run() -// } onGracefulShutdown: { -// logger.info("On Graceful Shutdown") -// } -// } -// group.addTask { -// try await Task.sleep(nanoseconds: 1_000_000_000) -// gracefulShutdownTestTrigger.triggerGracefulShutdown() -// } -// -// 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: .now(), logger: logger) -// -// let response = try await sut.handler(event: event, context: context) -// let handlerContext = try #require(await sut.handlerContext) -// #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() -// } -// } -// } + @Test("Handler delegates to specific handler implementation") + func handlerDelegatesToImplementation() async throws { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + 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) + + group.addTask { + try await withGracefulShutdownHandler { + try await sut.run() + } onGracefulShutdown: { + logger.info("On Graceful Shutdown") + } + } + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + + 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: .now(), logger: logger) + + let response = try await sut.handler(event: event, context: context) + let handlerContext = try #require(await sut.handlerContext) + #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() + } + } + } } struct MockHandler: BreezeLambdaWebHookHandler { From 84e71e2617bb1d7eed41a0663c082dca9e002ec9 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 16:15:07 +0100 Subject: [PATCH 05/11] Add Dooc --- .spi.yml | 4 ++++ Makefile | 6 +++++- Package.swift | 3 ++- .../APIGatewayV2Response+Extensions.swift | 11 +++++++++-- .../BreezeHTTPClientConfig.swift | 5 ++++- .../BreezeLambdaWebHook/BreezeLambdaWebHook.swift | 13 +++++++++++++ .../BreezeLambdaWebHookError.swift | 2 ++ .../BreezeLambdaWebHookHandler.swift | 2 ++ .../BreezeLambdaWebHookService.swift | 13 ++++++++++--- .../Docs.docc/theme-settings.json | 12 ++++++++++++ .../BreezeLambdaWebHookService.swift | 2 +- 11 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 .spi.yml create mode 100644 Sources/BreezeLambdaWebHook/Docs.docc/theme-settings.json diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..b71b832 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [BreezeLambdaWebHook] \ No newline at end of file diff --git a/Makefile b/Makefile index 14978cb..ff1a938 100644 --- a/Makefile +++ b/Makefile @@ -26,4 +26,8 @@ test: coverage: llvm-cov export $(TEST_PACKAGE) \ --instr-profile=$(SWIFT_BIN_PATH)/codecov/default.profdata \ - --format=lcov > $(GITHUB_WORKSPACE)/lcov.info \ No newline at end of file + --format=lcov > $(GITHUB_WORKSPACE)/lcov.info + +preview_docc_lambda_api: + swift package --disable-sandbox preview-documentation --target BreezeLambdaWebHook + diff --git a/Package.swift b/Package.swift index a82bed7..1066b85 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,8 @@ let package = Package( .package(url: "https://github.com/andrea-scuderi/swift-aws-lambda-runtime.git", branch: "main"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.5.0"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.22.0"), - .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.3") + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.3"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ .target( diff --git a/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift index c73bb42..1529c8d 100644 --- a/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift +++ b/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift @@ -16,14 +16,16 @@ import struct AWSLambdaEvents.APIGatewayV2Response import HTTPTypes import class Foundation.JSONEncoder +/// Extensions for `APIGatewayV2Response` to simplify response creation public extension APIGatewayV2Response { private static let encoder = JSONEncoder() + /// Body of an error response struct BodyError: Codable { public let error: String } - /// init + /// Initializer with body error and status code /// - Parameters: /// - error: Error /// - statusCode: HTTP Status Code @@ -32,11 +34,16 @@ public extension APIGatewayV2Response { self.init(with: bodyError, statusCode: statusCode) } - /// init + /// Initializer with decodable object, status code, and headers /// - Parameters: /// - object: Encodable Object /// - statusCode: HTTP Status Code /// - headers: HTTP Headers + /// - Returns: APIGatewayV2Response + /// + /// This initializer encodes the object to JSON and sets it as the body of the response. + /// If encoding fails, it defaults to an empty JSON object. + /// - Note: The `Content-Type` header is set to `application/json` by default. init( with object: Output, statusCode: HTTPResponse.Status, diff --git a/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift index 191fbad..b0e7a99 100644 --- a/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift +++ b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift @@ -15,10 +15,13 @@ import Logging import NIOCore +/// Error types for BreezeClientService public enum BreezeClientServiceError: Error { - case invalidHttpClient + /// The handler is invalid or not set + case invalidHandler } +/// Configuration for the Breeze HTTP Client public struct BreezeHTTPClientConfig: Sendable { public init(timeout: TimeAmount, logger: Logger) { self.timeout = timeout diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift index d3e1b1e..dcd8273 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift @@ -18,16 +18,29 @@ import ServiceLifecycle import Logging import NIOCore +/// The Service that handles Breeze Lambda WebHook functionality. public struct BreezeLambdaWebHook: Service { + /// The name of the service, used for logging and identification. public let name: String + /// Configuration for the Breeze HTTP Client. public let config: BreezeHTTPClientConfig + /// Initializes a new instance of with the given name and configuration. + /// - Parameters: + /// - name: The name of the service. + /// - config: Configuration for the Breeze HTTP Client. public init(name: String, config: BreezeHTTPClientConfig) { self.name = name self.config = config } + /// Runs the Breeze Lambda WebHook service. + /// - Throws: An error if the service fails to start or run. + /// + /// This method initializes the Breeze Lambda WebHook service and starts it, + /// handling any errors that may occur during the process. + /// It gracefully shuts down the service on termination signals. public func run() async throws { do { let lambdaService = BreezeLambdaWebHookService( diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift index b142b61..d5da97f 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift @@ -14,6 +14,8 @@ import Foundation +/// Error types for BreezeLambdaWebHook public enum BreezeLambdaWebHookError: Error { + /// The request is invalid or malformed case invalidRequest } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift index 35a610b..ad369d7 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift @@ -23,10 +23,12 @@ public protocol BreezeLambdaWebHookHandler: LambdaHandler { func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response } +/// A default implementation of the BreezeLambdaWebHookHandler protocol 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 index e487890..1340fee 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift @@ -27,13 +27,19 @@ public struct HandlerContext: Sendable { } } +/// 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 - private var isStarted = false + /// Initialilizer with a configuration for the Breeze HTTP Client. public init(config: BreezeHTTPClientConfig) { self.config = config let timeout = HTTPClient.Configuration.Timeout( @@ -47,8 +53,8 @@ public actor BreezeLambdaWebHookService: Se ) } + /// Runs the Breeze Lambda WebHook service. public func run() async throws { - isStarted = true let handlerContext = HandlerContext(httpClient: httpClient) self.handlerContext = handlerContext let runtime = LambdaRuntime(body: handler) @@ -61,9 +67,10 @@ public actor BreezeLambdaWebHookService: Se } } + /// Handler function that processes incoming events. func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { guard let handlerContext = handlerContext else { - throw BreezeClientServiceError.invalidHttpClient + throw BreezeClientServiceError.invalidHandler } return try await Handler(handlerContext: handlerContext).handle(event, context: context) } diff --git a/Sources/BreezeLambdaWebHook/Docs.docc/theme-settings.json b/Sources/BreezeLambdaWebHook/Docs.docc/theme-settings.json new file mode 100644 index 0000000..e4bfe52 --- /dev/null +++ b/Sources/BreezeLambdaWebHook/Docs.docc/theme-settings.json @@ -0,0 +1,12 @@ +{ + "theme": { + "color": { + "header": "#DE5E44", + "documentation-intro-title": "#FFFFFF", + "documentation-intro-fill": "linear-gradient(30deg, #DE5E44, #A2331D)", + "documentation-intro-accent": "#FFFFFF", + "documentation-intro-eyebrow": "#FFFFFF", + "link": "var(--color-header)" + } + } +} diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift index 88cf841..bf2b81e 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -74,7 +74,7 @@ struct BreezeLambdaWebHookServiceTests { let event = try decoder.decode(APIGatewayV2Request.self, from: createRequest) let context = LambdaContext(requestID: "req1", traceID: "trace1", invokedFunctionARN: "", deadline: .now(), logger: logger) - await #expect(throws: BreezeClientServiceError.invalidHttpClient) { + await #expect(throws: BreezeClientServiceError.invalidHandler) { try await service.handler(event: event, context: context) } } From ec2b6bf6e64165fded12bfe6c85110c746cff433 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 17:10:00 +0100 Subject: [PATCH 06/11] Add article and fix default config --- .../BreezeDemoHTTPApplication.swift | 15 +- .../BreezeHTTPClientConfig.swift | 5 +- .../BreezeLambdaWebHook.swift | 15 +- Sources/BreezeLambdaWebHook/Docs.docc/Docs.md | 155 ++++++++++++++++++ .../Docs.docc/Resources/webhook.svg | 1 + .../Docs.docc/Resources/wind.svg | 11 ++ 6 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 Sources/BreezeLambdaWebHook/Docs.docc/Docs.md create mode 100644 Sources/BreezeLambdaWebHook/Docs.docc/Resources/webhook.svg create mode 100644 Sources/BreezeLambdaWebHook/Docs.docc/Resources/wind.svg diff --git a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift index 3755713..e1330be 100644 --- a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift +++ b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift @@ -18,7 +18,6 @@ import AWSLambdaRuntime import AsyncHTTPClient import Logging import NIOCore -import ServiceLifecycle /// This is a simple example of a Breeze Lambda WebHook handler. /// It uses the BreezeHTTPClientService to make an HTTP request to example.com @@ -57,18 +56,8 @@ struct DemoLambdaHandler: BreezeLambdaWebHookHandler, Sendable { @main struct BreezeDemoHTTPApplication { - static let applicationName = "BreezeDemoHTTPApplication" - static let logger = Logger(label: "BreezeDemoHTTPApplication") - static func main() async throws { - let config = BreezeHTTPClientConfig( - timeout: .seconds(30), - logger: logger - ) - let app = BreezeLambdaWebHook( - name: applicationName, - config: config, - ) - try await app.run() + let lambda = BreezeLambdaWebHook(name: "DemoLambdaHandler") + try await lambda.run() } } diff --git a/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift index b0e7a99..a4297ff 100644 --- a/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift +++ b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift @@ -23,7 +23,10 @@ public enum BreezeClientServiceError: Error { /// Configuration for the Breeze HTTP Client public struct BreezeHTTPClientConfig: Sendable { - public init(timeout: TimeAmount, logger: Logger) { + public init( + timeout: TimeAmount = .seconds(30), + logger: Logger = Logger(label: "BreezeLambdaWebHookLogger") + ) { self.timeout = timeout self.logger = logger } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift index dcd8273..48c8143 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift @@ -30,9 +30,20 @@ public struct BreezeLambdaWebHook: Se /// - Parameters: /// - name: The name of the service. /// - config: Configuration for the Breeze HTTP Client. - public init(name: String, config: BreezeHTTPClientConfig) { + /// + /// This initializer sets up the Breeze Lambda WebHook service with a specified name and configuration. + /// + /// - Note: If no configuration is provided, a default configuration with a 30-second timeout and a logger will be used. + public init( + name: String, + config: BreezeHTTPClientConfig? = nil + ) { self.name = name - self.config = config + let defaultConfig = BreezeHTTPClientConfig( + timeout: .seconds(30), + logger: Logger(label: "\(name)") + ) + self.config = config ?? defaultConfig } /// Runs the Breeze Lambda WebHook service. diff --git a/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md b/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md new file mode 100644 index 0000000..0d575f6 --- /dev/null +++ b/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md @@ -0,0 +1,155 @@ +# ``BreezeLambdaWebHook`` + +@Metadata { + @PageImage(purpose: icon, source: "wind") +} + +## Overview + +BreezeLambdaWebHook is a Swift framework that simplifies the development of serverless webhook handlers for AWS Lambda. + +It provides a clean, type-safe interface for processing HTTP requests from API Gateway and returning appropriate responses. + +It allows you to define a handler that processes incoming webhook requests and returns appropriate responses using `AsyncHTTPClient` framework. + +![BreezeLambdaWebHook Diagram](webhook) + +## Key Features + +- Serverless Architecture - Built specifically for AWS Lambda with API Gateway integration +- Webhook Handling: Processes incoming requests and returns responses +- Swift Concurrency: Fully compatible with Swift's async/await model +- Type Safety: Leverages Swift's type system with Codable support +- It supports both GET and POST requests +- Minimal Configuration - Focus on business logic rather than infrastructure plumbing + +## How it works + +The framework handles the underlying AWS Lambda event processing, allowing you to focus on implementing your webhook logic. When a request arrives through API Gateway: + +1. The Lambda function receives the API Gateway event +2. BreezeLambdaWebHook deserializes the event into a Swift type +3. The handler processes the request, performing any necessary business logic +4. The handler returns a response, which is serialized back to the API Gateway format + +## Getting Started + +To create a webhook handler, implement the BreezeLambdaWebHookHandler protocol: + +```swift +class MyWebhook: BreezeLambdaWebHookHandler { + let handlerContext: HandlerContext + + required init(handlerContext: HandlerContext) { + self.handlerContext = handlerContext + } + + func handle(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + // Your webhook logic here + return APIGatewayV2Response(with: "Success", statusCode: .ok) + } +} +``` + +Then, implement the `main.swift` file to run the Lambda function: +```swift +import BreezeLambdaWebHook +import AWSLambdaEvents +import AWSLambdaRuntime +import AsyncHTTPClient +import Logging +import NIOCore + +@main +struct BreezeDemoHTTPApplication { + static func main() async throws { + let app = BreezeLambdaWebHook(name: "BreezeDemoHTTPApplication") + try await app.run() + } +} +``` + +## Example WebHook Handlers + +### GET WebHook example: + +If the parameter `github-user` is present in the query string, the value is extracted and used to get the content from GitHub, the content is returned to the response payload. + +```swift +class GetWebHook: BreezeLambdaWebHookHandler { + + let handlerContext: HandlerContext + + required init(handlerContext: HandlerContext) { + self.handlerContext = handlerContext + } + + func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: AWSLambdaEvents.APIGatewayV2Request) async -> AWSLambdaEvents.APIGatewayV2Response { + do { + context.logger.info("event: \(event)") + guard let params = event.queryStringParameters else { + throw BreezeLambdaWebHookError.invalidRequest + } + if let user = params["github-user"] { + let url = "https://github.com/\(user)" + let request = HTTPClientRequest(url: url) + let response = try await httpClient.execute(request, timeout: .seconds(3)) + let bytes = try await response.body.collect(upTo: 1024 * 1024) // 1 MB Buffer + let body = String(buffer: bytes) + return APIGatewayV2Response(with: body, statusCode: .ok) + } else { + return APIGatewayV2Response(with: params, statusCode: .ok) + } + } catch { + return APIGatewayV2Response(with: error, statusCode: .badRequest) + } + } +} +``` + +### PostWebHook example: + +If the parameter `github-user` is present in the JSON payload, the value is extracted and used to get the content from GitHub, the content is returned to the response payload. + +```swift +struct PostWebHookRequest: Codable { + let githubUser: String + + enum CodingKeys: String, CodingKey { + case githubUser = "github-user" + } +} + +class PostWebHook: BreezeLambdaWebHookHandler { + + let handlerContext: HandlerContext + + required init(handlerContext: HandlerContext) { + self.handlerContext = handlerContext + } + + func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: AWSLambdaEvents.APIGatewayV2Request) async -> AWSLambdaEvents.APIGatewayV2Response { + do { + context.logger.info("event: \(event)") + let incomingRequest: PostWebHookRequest = try event.bodyObject() + let url = "https://github.com/\(incomingRequest.githubUser)" + let request = HTTPClientRequest(url: url) + let response = try await httpClient.execute(request, timeout: .seconds(3)) + let bytes = try await response.body.collect(upTo: 1024 * 1024) // 1 MB Buffer + let body = String(buffer: bytes) + return APIGatewayV2Response(with: body, statusCode: .ok) + } catch { + return APIGatewayV2Response(with: error, statusCode: .badRequest) + } + } +} +``` + +## Deployment + +Deploy your Lambda function using AWS CDK, SAM, Serverless or Terraform. The Lambda requires: + +- API Gateway integration for HTTP requests + +For step-by-step deployment instructions and templates, see the [Breeze project repository](https://github.com/swift-serverless/Breeze) for more info on how to deploy it on AWS. + diff --git a/Sources/BreezeLambdaWebHook/Docs.docc/Resources/webhook.svg b/Sources/BreezeLambdaWebHook/Docs.docc/Resources/webhook.svg new file mode 100644 index 0000000..b9770dd --- /dev/null +++ b/Sources/BreezeLambdaWebHook/Docs.docc/Resources/webhook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/BreezeLambdaWebHook/Docs.docc/Resources/wind.svg b/Sources/BreezeLambdaWebHook/Docs.docc/Resources/wind.svg new file mode 100644 index 0000000..ce319fa --- /dev/null +++ b/Sources/BreezeLambdaWebHook/Docs.docc/Resources/wind.svg @@ -0,0 +1,11 @@ + + + + + + + + + From 45e355ccde286c459661297b3f44f235caa6d4df Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 17:16:39 +0100 Subject: [PATCH 07/11] Add package instructions --- Sources/BreezeLambdaWebHook/Docs.docc/Docs.md | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md b/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md index 0d575f6..ecae413 100644 --- a/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md +++ b/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md @@ -33,11 +33,42 @@ The framework handles the underlying AWS Lambda event processing, allowing you t 4. The handler returns a response, which is serialized back to the API Gateway format ## Getting Started + +Install the package by adding it to your `Package.swift` file: + +```swift +// swift-tools-version:6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "BreezeWebHook", + platforms: [ + .macOS(.v15) + ], + products: [ + .executable(name: "WebHook", targets: ["WebHook"]), + ], + dependencies: [ + .package(url: "https://github.com/swift-serverless/BreezeLambdaWebHook.git", branch: "main"), + .package(url: "https://github.com/andrea-scuderi/swift-aws-lambda-runtime.git", branch: "main"), + ], + targets: [ + .executableTarget( + name: "WebHook", + dependencies: [ + .product(name: "BreezeLambdaWebHook", package: "BreezeLambdaWebHook"), + ] + ) + ] +) +``` To create a webhook handler, implement the BreezeLambdaWebHookHandler protocol: ```swift -class MyWebhook: BreezeLambdaWebHookHandler { +class Webhook: BreezeLambdaWebHookHandler { let handlerContext: HandlerContext required init(handlerContext: HandlerContext) { @@ -51,7 +82,7 @@ class MyWebhook: BreezeLambdaWebHookHandler { } ``` -Then, implement the `main.swift` file to run the Lambda function: +Then, implement the `@main` to run the Lambda function: ```swift import BreezeLambdaWebHook import AWSLambdaEvents @@ -63,7 +94,7 @@ import NIOCore @main struct BreezeDemoHTTPApplication { static func main() async throws { - let app = BreezeLambdaWebHook(name: "BreezeDemoHTTPApplication") + let app = BreezeLambdaWebHook(name: "WebHook") try await app.run() } } From 8f8d439613384ccb506c47f65e489ef88f0e7e41 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 17:19:09 +0100 Subject: [PATCH 08/11] Disable meterian --- .github/workflows/{meterian.yml => meterian.yml.disabled} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{meterian.yml => meterian.yml.disabled} (100%) diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml.disabled similarity index 100% rename from .github/workflows/meterian.yml rename to .github/workflows/meterian.yml.disabled From 6484c349547426c93494e137b09dbe741bdbb7e8 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 17:21:27 +0100 Subject: [PATCH 09/11] Fix swift Test --- .github/workflows/swift-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/swift-test.yml b/.github/workflows/swift-test.yml index 0d15bfd..9100806 100644 --- a/.github/workflows/swift-test.yml +++ b/.github/workflows/swift-test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: image: - - swift:5.10.1 + - swift:6.1.2 container: image: ${{ matrix.image }} # Use the Bash shell regardless whether the GitHub Actions runner is ubuntu-latest, macos-latest, or windows-latest From e73ff7b321dac8cd986ebee378b2b991d3cbcddf Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 17:45:08 +0100 Subject: [PATCH 10/11] Fix Unit Tests on Linux --- .../BreezeLambdaWebHook/BreezeLambdaWebHookError.swift | 4 ++++ .../BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift | 4 ++++ .../BreezeLambdaWebHook/BreezeLambdaWebHookService.swift | 4 ++++ .../BreezeLambdaWebHookService.swift | 4 ++++ Tests/BreezeLambdaWebHookTests/Lambda.swift | 8 +++++++- Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift | 4 ++++ Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift | 4 ++++ Tests/BreezeLambdaWebHookTests/Utils.swift | 2 +- 8 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift index d5da97f..3ae90bc 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift @@ -12,7 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif /// Error types for BreezeLambdaWebHook public enum BreezeLambdaWebHookError: Error { diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift index ad369d7..22cadf3 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift @@ -15,7 +15,11 @@ import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif public protocol BreezeLambdaWebHookHandler: LambdaHandler { var handlerContext: HandlerContext { get } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift index 1340fee..ff2e2c0 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift @@ -15,7 +15,11 @@ import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif import ServiceLifecycle import Logging diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift index bf2b81e..f340021 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -20,7 +20,11 @@ import AWSLambdaRuntime import ServiceLifecycleTestKit @testable import BreezeLambdaWebHook import Logging +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif import NIOCore diff --git a/Tests/BreezeLambdaWebHookTests/Lambda.swift b/Tests/BreezeLambdaWebHookTests/Lambda.swift index 180fcff..fbd1942 100644 --- a/Tests/BreezeLambdaWebHookTests/Lambda.swift +++ b/Tests/BreezeLambdaWebHookTests/Lambda.swift @@ -12,11 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif import AsyncHTTPClient import AWSLambdaEvents import Logging import NIO +import NIOFoundationCompat @testable import BreezeLambdaWebHook @testable import AWSLambdaRuntime @@ -63,8 +68,9 @@ 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: result) + return try decoder.decode(APIGatewayV2Response.self, from: value) } } diff --git a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift index 6f4eb89..7afe54c 100644 --- a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift @@ -12,7 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif import BreezeLambdaWebHook import AsyncHTTPClient import AWSLambdaEvents diff --git a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift index 31d9a04..b039a55 100644 --- a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift @@ -12,7 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif import BreezeLambdaWebHook import AsyncHTTPClient import AWSLambdaEvents diff --git a/Tests/BreezeLambdaWebHookTests/Utils.swift b/Tests/BreezeLambdaWebHookTests/Utils.swift index a69b48d..688d188 100644 --- a/Tests/BreezeLambdaWebHookTests/Utils.swift +++ b/Tests/BreezeLambdaWebHookTests/Utils.swift @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import AWSLambdaEvents +import Foundation func setEnvironmentVar(name: String, value: String, overwrite: Bool) { setenv(name, value, overwrite ? 1 : 0) From 637c7cbf2bbcade93b992a8e2f77079a10bd6a39 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 13 Jul 2025 18:04:14 +0100 Subject: [PATCH 11/11] Improve concurrent Tests --- .../BreezeLambdaWebHookService.swift | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift index f340021..0d329c6 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -44,26 +44,32 @@ struct BreezeLambdaWebHookServiceTests { @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 sut = BreezeLambdaWebHookService(config: config) - - await withTaskCancellationOrGracefulShutdownHandler { - group.addTask { + 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() } - } onCancelOrGracefulShutdown: { - gracefulShutdownTestTrigger.triggerGracefulShutdown() - logger.info("On Graceful Shutdown") } - - try await Task.sleep(nanoseconds: 1_000_000_000) - group.cancelAll() - - let handlerContext = try #require(await sut.handlerContext) - #expect(handlerContext.httpClient.configuration.timeout.read == .seconds(30)) - #expect(handlerContext.httpClient.configuration.timeout.connect == .seconds(30)) + for await _ in gracefulStream { + 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() + } } } } @@ -86,36 +92,39 @@ struct BreezeLambdaWebHookServiceTests { @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 sut = BreezeLambdaWebHookService(config: config) - + 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() } } - group.addTask { - try await Task.sleep(nanoseconds: 1_000_000_000) - gracefulShutdownTestTrigger.triggerGracefulShutdown() + 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: .now(), logger: logger) + + let response = try await sut.handler(event: event, context: context) + let handlerContext = try #require(await sut.handlerContext) + #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() } - - 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: .now(), logger: logger) - - let response = try await sut.handler(event: event, context: context) - let handlerContext = try #require(await sut.handlerContext) - #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() } } }