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 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 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 df0851e..1066b85 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,19 +6,24 @@ import PackageDescription let package = Package( name: "BreezeLambdaWebHook", platforms: [ - .macOS(.v13), - .iOS(.v15) + .macOS(.v15) ], products: [ .library( name: "BreezeLambdaWebHook", targets: ["BreezeLambdaWebHook"] + ), + .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/async-http-client.git", from: "1.11.2"), + .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/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ .target( @@ -29,10 +34,17 @@ let package = Package( .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), + .executableTarget( + name: "BreezeDemoHTTPApplication", + dependencies: [ + "BreezeLambdaWebHook" + ] + ), .testTarget( name: "BreezeLambdaWebHookTests", dependencies: [ - .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "BreezeLambdaWebHook" ], resources: [.copy("Fixtures")] diff --git a/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift new file mode 100644 index 0000000..e1330be --- /dev/null +++ b/Sources/BreezeDemoHTTPApplication/BreezeDemoHTTPApplication.swift @@ -0,0 +1,63 @@ +// 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 BreezeLambdaWebHook +import AWSLambdaEvents +import AWSLambdaRuntime +import AsyncHTTPClient +import Logging +import NIOCore + +/// 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 + + init(handlerContext: HandlerContext) { + self.handlerContext = handlerContext + } + + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + context.logger.info("Received event: \(event)") + let request = HTTPClientRequest(url: "https://example.com") + let response = try await handlerContext.httpClient.execute(request, timeout: .seconds(5)) + let 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 BreezeDemoHTTPApplication { + + static func main() async throws { + let lambda = BreezeLambdaWebHook(name: "DemoLambdaHandler") + try await lambda.run() + } +} diff --git a/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift index 237f8aa..1529c8d 100644 --- a/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift +++ b/Sources/BreezeLambdaWebHook/APIGatewayV2Response+Extensions.swift @@ -16,18 +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() - - /// defaultHeaders - /// Override the headers in APIGatewayV2Response - static var defaultHeaders = [ "Content-Type": "application/json" ] + /// 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 @@ -36,18 +34,28 @@ 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 - init(with object: Output, statusCode: HTTPResponse.Status) { + /// - 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, + 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/BreezeHTTPClientConfig.swift b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift new file mode 100644 index 0000000..a4297ff --- /dev/null +++ b/Sources/BreezeLambdaWebHook/BreezeHTTPClientConfig.swift @@ -0,0 +1,36 @@ +// 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 +import NIOCore + +/// Error types for BreezeClientService +public enum BreezeClientServiceError: Error { + /// The handler is invalid or not set + case invalidHandler +} + +/// Configuration for the Breeze HTTP Client +public struct BreezeHTTPClientConfig: Sendable { + public init( + timeout: TimeAmount = .seconds(30), + logger: Logger = Logger(label: "BreezeLambdaWebHookLogger") + ) { + 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 index 4f5f668..48c8143 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHook.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. @@ -12,65 +12,60 @@ // See the License for the specific language governing permissions and // limitations under the License. -import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime -import AWSLambdaRuntimeCore -import Foundation +import ServiceLifecycle +import Logging +import NIOCore -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 +/// The Service that handles Breeze Lambda WebHook functionality. +public struct BreezeLambdaWebHook: Service { - 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 + /// 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. + /// + /// 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 + let defaultConfig = BreezeHTTPClientConfig( + timeout: .seconds(30), + logger: Logger(label: "\(name)") ) - - 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 - } + self.config = config ?? defaultConfig } - - public func handle(_ event: AWSLambdaEvents.APIGatewayV2Request, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> AWSLambdaEvents.APIGatewayV2Response { - return await Handler(handlerContext: handlerContext).handle(context: context, event: event) + + /// 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( + config: config + ) + let serviceGroup = ServiceGroup( + services: [lambdaService], + gracefulShutdownSignals: [.sigterm, .sigint], + 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/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift index b142b61..3ae90bc 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookError.swift @@ -12,8 +12,14 @@ // 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 { + /// The request is invalid or malformed case invalidRequest } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift index 982c9a3..22cadf3 100644 --- a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookHandler.swift @@ -15,19 +15,24 @@ import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime -import AWSLambdaRuntimeCore +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif -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 } +/// A default implementation of the BreezeLambdaWebHookHandler protocol public extension BreezeLambdaWebHookHandler { var handler: String? { - handlerContext.handler + Lambda.env("_HANDLER") } + var httpClient: AsyncHTTPClient.HTTPClient { handlerContext.httpClient } diff --git a/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift new file mode 100644 index 0000000..ff2e2c0 --- /dev/null +++ b/Sources/BreezeLambdaWebHook/BreezeLambdaWebHookService.swift @@ -0,0 +1,108 @@ +// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import AsyncHTTPClient +import AWSLambdaEvents +import AWSLambdaRuntime +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import ServiceLifecycle +import Logging + +public struct HandlerContext: Sendable { + public let httpClient: HTTPClient + + public init(httpClient: HTTPClient) { + self.httpClient = httpClient + } +} + +/// A service that runs a Breeze Lambda WebHook handler +/// +/// This service is responsible for providing the necessary context and configuration to the handler, +/// including the HTTP client and any other required resources. +/// +/// - Note: This service is designed to be used with the Breeze Lambda WebHook framework, which allows for handling webhooks in a serverless environment. +public actor BreezeLambdaWebHookService: Service { + + let config: BreezeHTTPClientConfig + var handlerContext: HandlerContext? + let httpClient: HTTPClient + + /// Initialilizer with a configuration for the Breeze HTTP Client. + public init(config: BreezeHTTPClientConfig) { + self.config = config + let timeout = HTTPClient.Configuration.Timeout( + connect: config.timeout, + read: config.timeout + ) + let configuration = HTTPClient.Configuration(timeout: timeout) + httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: configuration + ) + } + + /// Runs the Breeze Lambda WebHook service. + public func run() async throws { + let handlerContext = HandlerContext(httpClient: httpClient) + self.handlerContext = handlerContext + let runtime = LambdaRuntime(body: handler) + try await runTaskWithCancellationOnGracefulShutdown { + try await runtime.run() + } onGracefulShutdown: { + self.config.logger.info("Shutting down HTTP client...") + _ = self.httpClient.shutdown() + self.config.logger.info("HTTP client has been shut down.") + } + } + + /// Handler function that processes incoming events. + func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + guard let handlerContext = handlerContext else { + throw BreezeClientServiceError.invalidHandler + } + return try await Handler(handlerContext: handlerContext).handle(event, context: context) + } + + /// Runs a task with cancellation on graceful shutdown. + /// + /// - Note: It's required to allow a full process shutdown without leaving tasks hanging. + private func runTaskWithCancellationOnGracefulShutdown( + operation: @escaping @Sendable () async throws -> Void, + onGracefulShutdown: () async throws -> Void + ) async throws { + let (cancelOrGracefulShutdown, cancelOrGracefulShutdownContinuation) = AsyncStream.makeStream() + let task = Task { + try await withTaskCancellationOrGracefulShutdownHandler { + try await operation() + } onCancelOrGracefulShutdown: { + cancelOrGracefulShutdownContinuation.yield() + cancelOrGracefulShutdownContinuation.finish() + } + } + for await _ in cancelOrGracefulShutdown { + try await onGracefulShutdown() + task.cancel() + } + } + + deinit { + _ = httpClient.shutdown() + } +} + diff --git a/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md b/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md new file mode 100644 index 0000000..ecae413 --- /dev/null +++ b/Sources/BreezeLambdaWebHook/Docs.docc/Docs.md @@ -0,0 +1,186 @@ +# ``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 + +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 Webhook: 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` 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: "WebHook") + 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 @@ + + + + + + + + + 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 new file mode 100644 index 0000000..0d329c6 --- /dev/null +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookService.swift @@ -0,0 +1,146 @@ +// 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 +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +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 + 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() + } + } + 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() + } + } + } + } + + @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.invalidHandler) { + try await service.handler(event: event, context: context) + } + } + + @Test("Handler delegates to specific handler implementation") + func handlerDelegatesToImplementation() async throws { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + 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() + } + } + 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() + } + } + } + } +} + +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 8fec216..9cea1db 100644 --- a/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift +++ b/Tests/BreezeLambdaWebHookTests/BreezeLambdaWebHookTests.swift @@ -12,73 +12,80 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Testing import AWSLambdaEvents import AWSLambdaRuntime -import AWSLambdaTesting import AsyncHTTPClient @testable import BreezeLambdaWebHook -import XCTest +import Logging +import Foundation -final class BreezeLambdaWebHookTests: XCTestCase { +@Suite("BreezeLambdaWebHookSuite") +struct BreezeLambdaWebHookTests: ~Copyable { let decoder = JSONDecoder() - - override func setUpWithError() throws { - try super.setUpWithError() + let config = BreezeHTTPClientConfig( + timeout: .seconds(1), + logger: Logger(label: "test") + ) + + init() { + setEnvironmentVar(name: "_HANDLER", value: "build/webhook", overwrite: true) setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) - 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(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" ]) - 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(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" ]) 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(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" ]) - 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(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" ]) - 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 8ec17f5..fbd1942 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. @@ -12,44 +12,85 @@ // 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 AWSLambdaRuntime -@testable import AWSLambdaRuntimeCore -import AWSLambdaTesting import Logging import NIO +import NIOFoundationCompat +@testable import BreezeLambdaWebHook +@testable import AWSLambdaRuntime + extension Lambda { - public static func test( - _ handlerType: Handler.Type, - with event: Handler.Event, - using config: TestConfig = .init() - ) 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: 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) + } + + 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() + let value = Data(result.readableBytesView) + try await httpClient.shutdown() + return try decoder.decode(APIGatewayV2Response.self, from: value) } - let eventLoop = eventLoopGroup.next() - - let initContext = LambdaInitializationContext.__forTestsOnly( - logger: logger, - eventLoop: eventLoop - ) +} - let context = LambdaContext.__forTestsOnly( - requestID: config.requestID, - traceID: config.traceID, - invokedFunctionARN: config.invokedFunctionARN, - timeout: config.timeout, - logger: logger, - eventLoop: eventLoop - ) - 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) +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 a685fda..7afe54c 100644 --- a/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyGetWebHook.swift @@ -12,11 +12,15 @@ // 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 -import AWSLambdaRuntimeCore +import AWSLambdaRuntime class MyGetWebHook: BreezeLambdaWebHookHandler { @@ -26,7 +30,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..b039a55 100644 --- a/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift +++ b/Tests/BreezeLambdaWebHookTests/MyPostWebHook.swift @@ -12,11 +12,15 @@ // 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 -import AWSLambdaRuntimeCore +import AWSLambdaRuntime struct MyPostResponse: Codable { let handler: String? @@ -35,7 +39,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/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) 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