From 3c7df317dfa3a7c910fecb0b5a46913d7d6d5d11 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 21 Dec 2024 16:34:18 +0100 Subject: [PATCH 01/29] Use ServiceLifecycle to setup Lambda --- Package.swift | 38 ++++- .../BreezeDemoApplication.swift | 24 +++ .../BreezeDynamoDBService/BreezeCodable.swift | 2 +- .../BreezeDynamoDBManager.swift | 123 +++++++++++++++ ...ing.swift => BreezeDynamoDBManaging.swift} | 2 +- .../BreezeDynamoDBService.swift | 142 ++++++------------ .../BreezeHTTPClientService.swift | 47 ++++++ .../APIGatewayV2Response+Extensions.swift | 2 +- Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift | 124 --------------- .../BreezeLambdaAPIError.swift | 1 + .../BreezeLambdaAPIHandler.swift | 49 ++++++ .../BreezeLambdaAPIService.swift | 96 ++++++++++++ .../BreezeLambdaAPI/BreezeLambdaHandler.swift | 2 +- .../BreezeLambdaAPI/BreezeLambdaService.swift | 36 +++++ .../LocalStackDynamoDB.swift | 8 +- .../BreezeDynamoDBServiceMock.swift | 2 +- .../BreezeLambdaAPITests.swift | 42 +++--- Tests/BreezeLambdaAPITests/Lambda.swift | 70 ++++----- docker/Dockerfile | 2 +- 19 files changed, 520 insertions(+), 292 deletions(-) create mode 100644 Sources/BreezeDemoApplication/BreezeDemoApplication.swift create mode 100644 Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift rename Sources/BreezeDynamoDBService/{BreezeDynamoDBServing.swift => BreezeDynamoDBManaging.swift} (95%) create mode 100644 Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift delete mode 100644 Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift create mode 100644 Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift create mode 100644 Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift create mode 100644 Sources/BreezeLambdaAPI/BreezeLambdaService.swift diff --git a/Package.swift b/Package.swift index 2173617..baae143 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// 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 @@ -6,32 +6,55 @@ import PackageDescription let package = Package( name: "BreezeLambdaDynamoDBAPI", platforms: [ - .macOS(.v13), + .macOS(.v15), ], products: [ .library( name: "BreezeDynamoDBService", targets: ["BreezeDynamoDBService"] ), + .library( + name: "BreezeHTTPClientService", + targets: ["BreezeHTTPClientService"] + ), .library( name: "BreezeLambdaAPI", targets: ["BreezeLambdaAPI"] + ), + .executable( + name: "BreezeDemoApplication", + targets: ["BreezeDemoApplication"] ) ], 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", branch: "main"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), .package(url: "https://github.com/soto-project/soto.git", from: "6.7.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/swift-serverless/swift-sls-adapter", from: "0.2.1"), - .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"), ], targets: [ + .executableTarget( + name: "BreezeDemoApplication", + dependencies: [ + "BreezeLambdaAPI" + ] + ), + .target( + name: "BreezeHTTPClientService", + dependencies: [ + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Logging", package: "swift-log") + ] + ), .target( name: "BreezeDynamoDBService", dependencies: [ .product(name: "SotoDynamoDB", package: "soto"), - .product(name: "Logging", package: "swift-log") + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "Logging", package: "swift-log"), + "BreezeHTTPClientService" ] ), .target( @@ -39,6 +62,7 @@ let package = Package( dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), "BreezeDynamoDBService" ] ), diff --git a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift new file mode 100644 index 0000000..39d2508 --- /dev/null +++ b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift @@ -0,0 +1,24 @@ +// +// BreezeDemoApplication.swift +// BreezeLambdaDynamoDBAPI +// +// Created by Andrea Scuderi on 21/12/2024. +// + +import BreezeLambdaAPI +import BreezeDynamoDBService + +struct Message: BreezeCodable { + var key: String + let message: String + var createdAt: String? + var updatedAt: String? +} + +@main +struct BreezeDemoApplication { + static func main() async throws { + let lambdaAPIService = try BreezeLambdaAPIService(dbTimeout: 30) + try await lambdaAPIService.run() + } +} diff --git a/Sources/BreezeDynamoDBService/BreezeCodable.swift b/Sources/BreezeDynamoDBService/BreezeCodable.swift index 29f8f46..2dc3084 100644 --- a/Sources/BreezeDynamoDBService/BreezeCodable.swift +++ b/Sources/BreezeDynamoDBService/BreezeCodable.swift @@ -14,7 +14,7 @@ import Foundation -public protocol BreezeCodable: Codable { +public protocol BreezeCodable: Codable, Sendable { var key: String { get set } var createdAt: String? { get set } var updatedAt: String? { get set } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift new file mode 100644 index 0000000..f21923c --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift @@ -0,0 +1,123 @@ +// 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 struct Foundation.Date +import NIO +import SotoDynamoDB + +public struct BreezeDynamoDBManager: BreezeDynamoDBManaging { + enum ServiceError: Error { + case notFound + case missingParameters + } + + let db: DynamoDB + public let keyName: String + let tableName: String + + public init(db: DynamoDB, tableName: String, keyName: String) { + self.db = db + self.tableName = tableName + self.keyName = keyName + } +} + +public extension BreezeDynamoDBManager { + func createItem(item: T) async throws -> T { + var item = item + let date = Date() + item.createdAt = date.iso8601 + item.updatedAt = date.iso8601 + let input = DynamoDB.PutItemCodableInput( + conditionExpression: "attribute_not_exists(#keyName)", + expressionAttributeNames: ["#keyName": keyName], + item: item, + tableName: tableName + ) + let _ = try await db.putItem(input) + return try await readItem(key: item.key) + } + + func readItem(key: String) async throws -> T { + let input = DynamoDB.GetItemInput( + key: [keyName: DynamoDB.AttributeValue.s(key)], + tableName: tableName + ) + let data = try await db.getItem(input, type: T.self) + guard let item = data.item else { + throw ServiceError.notFound + } + return item + } + + private struct AdditionalAttributes: Encodable { + let oldUpdatedAt: String + } + + func updateItem(item: T) async throws -> T { + var item = item + let oldUpdatedAt = item.updatedAt ?? "" + let date = Date() + item.updatedAt = date.iso8601 + let attributes = AdditionalAttributes(oldUpdatedAt: oldUpdatedAt) + let input = try DynamoDB.UpdateItemCodableInput( + additionalAttributes: attributes, + conditionExpression: "attribute_exists(#\(keyName)) AND #updatedAt = :oldUpdatedAt AND #createdAt = :createdAt", + key: [keyName], + tableName: tableName, + updateItem: item + ) + let _ = try await db.updateItem(input) + return try await readItem(key: item.key) + } + + func deleteItem(item: T) async throws { + guard let updatedAt = item.updatedAt, + let createdAt = item.createdAt else { + throw ServiceError.missingParameters + } + + let input = DynamoDB.DeleteItemInput( + conditionExpression: "#updatedAt = :updatedAt AND #createdAt = :createdAt", + expressionAttributeNames: ["#updatedAt": "updatedAt", + "#createdAt" : "createdAt"], + expressionAttributeValues: [":updatedAt": .s(updatedAt), + ":createdAt" : .s(createdAt)], + key: [keyName: DynamoDB.AttributeValue.s(item.key)], + tableName: tableName + ) + let _ = try await db.deleteItem(input) + return + } + + func listItems(key: String?, limit: Int?) async throws -> ListResponse { + var exclusiveStartKey: [String: DynamoDB.AttributeValue]? + if let key { + exclusiveStartKey = [keyName: DynamoDB.AttributeValue.s(key)] + } + let input = DynamoDB.ScanInput( + exclusiveStartKey: exclusiveStartKey, + limit: limit, + tableName: tableName + ) + let data = try await db.scan(input, type: T.self) + if let lastEvaluatedKeyShape = data.lastEvaluatedKey?[keyName], + case .s(let lastEvaluatedKey) = lastEvaluatedKeyShape + { + return ListResponse(items: data.items ?? [], lastEvaluatedKey: lastEvaluatedKey) + } else { + return ListResponse(items: data.items ?? [], lastEvaluatedKey: nil) + } + } +} diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift similarity index 95% rename from Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift rename to Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift index 9713ca4..3f5caf9 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift @@ -14,7 +14,7 @@ import SotoDynamoDB -public protocol BreezeDynamoDBServing { +public protocol BreezeDynamoDBManaging: Sendable { var keyName: String { get } init(db: DynamoDB, tableName: String, keyName: String) func createItem(item: Item) async throws -> Item diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index ce4d34d..a6a2b06 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -12,112 +12,64 @@ // See the License for the specific language governing permissions and // limitations under the License. -import struct Foundation.Date -import NIO import SotoDynamoDB - -public class BreezeDynamoDBService: BreezeDynamoDBServing { - enum ServiceError: Error { - case notFound - case missingParameters - } - - let db: DynamoDB - public let keyName: String - let tableName: String - - public required init(db: DynamoDB, tableName: String, keyName: String) { - self.db = db - self.tableName = tableName - self.keyName = keyName - } -} +import ServiceLifecycle +import BreezeHTTPClientService +import Logging public extension BreezeDynamoDBService { - func createItem(item: T) async throws -> T { - var item = item - let date = Date() - item.createdAt = date.iso8601 - item.updatedAt = date.iso8601 - let input = DynamoDB.PutItemCodableInput( - conditionExpression: "attribute_not_exists(#keyName)", - expressionAttributeNames: ["#keyName": keyName], - item: item, - tableName: tableName - ) - let _ = try await db.putItem(input) - return try await readItem(key: item.key) + enum DynamoDB { + public static let Service: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self } +} - func readItem(key: String) async throws -> T { - let input = DynamoDB.GetItemInput( - key: [keyName: DynamoDB.AttributeValue.s(key)], - tableName: tableName - ) - let data = try await db.getItem(input, type: T.self) - guard let item = data.item else { - throw ServiceError.notFound +public actor BreezeDynamoDBService: Service { + + public struct Config: Sendable { + + let httpClientService: BreezeHTTPClientService + let region: Region + let tableName: String + let keyName: String + let logger: Logger + + public init(httpClientService: BreezeHTTPClientService, region: Region, tableName: String, keyName: String, logger: Logger) { + self.httpClientService = httpClientService + self.region = region + self.tableName = tableName + self.keyName = keyName + self.logger = logger } - return item } - private struct AdditionalAttributes: Encodable { - let oldUpdatedAt: String - } + public var dbManager: BreezeDynamoDBManaging? + private var awsClient: AWSClient? + private let config: Config - func updateItem(item: T) async throws -> T { - var item = item - let oldUpdatedAt = item.updatedAt ?? "" - let date = Date() - item.updatedAt = date.iso8601 - let attributes = AdditionalAttributes(oldUpdatedAt: oldUpdatedAt) - let input = try DynamoDB.UpdateItemCodableInput( - additionalAttributes: attributes, - conditionExpression: "attribute_exists(#\(keyName)) AND #updatedAt = :oldUpdatedAt AND #createdAt = :createdAt", - key: [keyName], - tableName: tableName, - updateItem: item - ) - let _ = try await db.updateItem(input) - return try await readItem(key: item.key) + public init(with config: Config) { + self.config = config } - - func deleteItem(item: T) async throws { - guard let updatedAt = item.updatedAt, - let createdAt = item.createdAt else { - throw ServiceError.missingParameters - } + + public func run() async throws { + config.logger.info("Starting DynamoDBService...") + let httpClient = await config.httpClientService.httpClient + let awsClient = AWSClient(httpClientProvider: .shared(httpClient)) + let db = SotoDynamoDB.DynamoDB(client: awsClient, region: config.region) - let input = DynamoDB.DeleteItemInput( - conditionExpression: "#updatedAt = :updatedAt AND #createdAt = :createdAt", - expressionAttributeNames: ["#updatedAt": "updatedAt", - "#createdAt" : "createdAt"], - expressionAttributeValues: [":updatedAt": .s(updatedAt), - ":createdAt" : .s(createdAt)], - key: [keyName: DynamoDB.AttributeValue.s(item.key)], - tableName: tableName - ) - let _ = try await db.deleteItem(input) - return - } - - func listItems(key: String?, limit: Int?) async throws -> ListResponse { - var exclusiveStartKey: [String: DynamoDB.AttributeValue]? - if let key { - exclusiveStartKey = [keyName: DynamoDB.AttributeValue.s(key)] - } - let input = DynamoDB.ScanInput( - exclusiveStartKey: exclusiveStartKey, - limit: limit, - tableName: tableName + self.dbManager = DynamoDB.Service.init( + db: db, + tableName: config.tableName, + keyName: config.keyName ) - let data = try await db.scan(input, type: T.self) - if let lastEvaluatedKeyShape = data.lastEvaluatedKey?[keyName], - case .s(let lastEvaluatedKey) = lastEvaluatedKeyShape - { - return ListResponse(items: data.items ?? [], lastEvaluatedKey: lastEvaluatedKey) - } else { - return ListResponse(items: data.items ?? [], lastEvaluatedKey: nil) - } + config.logger.info("DynamoDBService config...") + config.logger.info("region: \(config.region)") + config.logger.info("tableName: \(config.tableName)") + config.logger.info("keyName: \(config.keyName)") + + try await gracefulShutdown() + + config.logger.info("Shutting down DynamoDBService...") + try self.awsClient?.syncShutdown() } } + diff --git a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift new file mode 100644 index 0000000..3fd95ba --- /dev/null +++ b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift @@ -0,0 +1,47 @@ +// 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 ServiceLifecycle +import AsyncHTTPClient +import NIOCore +import Logging + +public actor BreezeHTTPClientService: Service { + + 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("Shutting down HTTPClientService...") + try await httpClient.shutdown() + } +} diff --git a/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift index 8da9234..1be3274 100644 --- a/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift +++ b/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift @@ -21,7 +21,7 @@ extension APIGatewayV2Response { /// defaultHeaders /// Override the headers in APIGatewayV2Response - static var defaultHeaders = [ "Content-Type": "application/json" ] + static let defaultHeaders = [ "Content-Type": "application/json" ] struct BodyError: Codable { let error: String diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift deleted file mode 100644 index a350d20..0000000 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift +++ /dev/null @@ -1,124 +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 AWSLambdaRuntimeCore -import BreezeDynamoDBService -import Foundation -import SotoDynamoDB - -public extension LambdaInitializationContext { - enum DynamoDB { - public static var Service: BreezeDynamoDBServing.Type = BreezeDynamoDBService.self - public static var dbTimeout: Int64 = 30 - } -} - -public class BreezeLambdaAPI: LambdaHandler { - public typealias Event = APIGatewayV2Request - public typealias Output = APIGatewayV2Response - - let dbTimeout: Int64 - let region: Region - let db: SotoDynamoDB.DynamoDB - let service: BreezeDynamoDBServing - let tableName: String - let keyName: String - let operation: BreezeOperation - var httpClient: HTTPClient - - static func currentRegion() -> Region { - if let awsRegion = Lambda.env("AWS_REGION") { - let value = Region(rawValue: awsRegion) - return value - } else { - return .useast1 - } - } - - static func tableName() throws -> String { - guard let tableName = Lambda.env("DYNAMO_DB_TABLE_NAME") else { - throw BreezeLambdaAPIError.tableNameNotFound - } - return tableName - } - - static func keyName() throws -> String { - guard let tableName = Lambda.env("DYNAMO_DB_KEY") else { - throw BreezeLambdaAPIError.keyNameNotFound - } - return tableName - } - - public required init(context: LambdaInitializationContext) async throws { - guard let handler = Lambda.env("_HANDLER"), - let operation = BreezeOperation(handler: handler) - else { - throw BreezeLambdaAPIError.invalidHandler - } - self.operation = operation - context.logger.info("operation: \(operation)") - self.region = Self.currentRegion() - context.logger.info("region: \(region)") - self.dbTimeout = LambdaInitializationContext.DynamoDB.dbTimeout - context.logger.info("dbTimeout: \(dbTimeout)") - self.tableName = try Self.tableName() - context.logger.info("tableName: \(tableName)") - self.keyName = try Self.keyName() - context.logger.info("keyName: \(keyName)") - - let lambdaRuntimeTimeout: TimeAmount = .seconds(dbTimeout) - let timeout = HTTPClient.Configuration.Timeout( - connect: lambdaRuntimeTimeout, - read: lambdaRuntimeTimeout - ) - - let configuration = HTTPClient.Configuration(timeout: timeout) - self.httpClient = HTTPClient( - eventLoopGroupProvider: .shared(context.eventLoop), - configuration: configuration - ) - - let awsClient = AWSClient(httpClientProvider: .shared(self.httpClient)) - self.db = SotoDynamoDB.DynamoDB(client: awsClient, region: self.region) - - self.service = LambdaInitializationContext.DynamoDB.Service.init( - db: self.db, - tableName: self.tableName, - keyName: self.keyName - ) - - context.terminator.register(name: "shutdown") { eventLoop in - context.logger.info("shutdown: started") - let promise = eventLoop.makePromise(of: Void.self) - Task { - do { - try awsClient.syncShutdown() - try await self.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 BreezeLambdaHandler(service: self.service, operation: self.operation).handle(context: context, event: event) - } -} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift index 2d476bb..cf7aaa6 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift @@ -20,4 +20,5 @@ enum BreezeLambdaAPIError: Error { case keyNameNotFound case invalidRequest case invalidHandler + case invalidService } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift new file mode 100644 index 0000000..6430c93 --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift @@ -0,0 +1,49 @@ +// 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 BreezeDynamoDBService +import Foundation +import SotoDynamoDB + +public struct BreezeLambdaAPIHandler: LambdaHandler, Sendable { + public typealias Event = APIGatewayV2Request + public typealias Output = APIGatewayV2Response + + let service: BreezeDynamoDBService + + public init(service: BreezeDynamoDBService) async throws { + self.service = service + } + + static func operation() throws -> BreezeOperation { + guard let handler = Lambda.env("_HANDLER"), + let operation = BreezeOperation(handler: handler) + else { + throw BreezeLambdaAPIError.invalidHandler + } + return operation + } + + public func handle(_ event: AWSLambdaEvents.APIGatewayV2Request, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> AWSLambdaEvents.APIGatewayV2Response { + let operation = try Self.operation() + context.logger.info("operation: \(operation)") + guard let service = await service.dbManager else { + throw BreezeLambdaAPIError.invalidService + } + return await BreezeLambdaHandler(service: service, operation: operation).handle(context: context, event: event) + } +} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift new file mode 100644 index 0000000..4d7f599 --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift @@ -0,0 +1,96 @@ +// 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 SotoDynamoDB +import ServiceLifecycle +import BreezeDynamoDBService +import BreezeHTTPClientService +import AWSLambdaRuntime + +public actor BreezeLambdaAPIService: Service { + + let logger = Logger(label: "service-group") + let timeout: TimeAmount + let httpClientService: BreezeHTTPClientService + let dynamoDBService: BreezeDynamoDBService + let breezeLambdaService: BreezeLambdaService + private let serviceGroup: ServiceGroup + + static func currentRegion() -> Region { + if let awsRegion = Lambda.env("AWS_REGION") { + let value = Region(rawValue: awsRegion) + return value + } else { + return .useast1 + } + } + + static func tableName() throws -> String { + guard let tableName = Lambda.env("DYNAMO_DB_TABLE_NAME") else { + throw BreezeLambdaAPIError.tableNameNotFound + } + return tableName + } + + static func keyName() throws -> String { + guard let tableName = Lambda.env("DYNAMO_DB_KEY") else { + throw BreezeLambdaAPIError.keyNameNotFound + } + return tableName + } + + public init(dbTimeout: Int64 = 30) throws { + self.timeout = .seconds(dbTimeout) + self.httpClientService = BreezeHTTPClientService( + timeout: timeout, + logger: logger + ) + let config = BreezeDynamoDBService.Config( + httpClientService: httpClientService, + region: Self.currentRegion(), + tableName: try Self.tableName(), + keyName: try Self.keyName(), + logger: logger + ) + self.dynamoDBService = BreezeDynamoDBService(with: config) + self.breezeLambdaService = BreezeLambdaService(dynamoDBService: dynamoDBService) + + self.serviceGroup = ServiceGroup( + configuration: .init( + services: [ + .init( + service: httpClientService, + successTerminationBehavior: .ignore, + failureTerminationBehavior: .gracefullyShutdownGroup + ), + .init( + service: dynamoDBService, + successTerminationBehavior: .gracefullyShutdownGroup, + failureTerminationBehavior: .gracefullyShutdownGroup + ), + .init( + service: breezeLambdaService, + successTerminationBehavior: .gracefullyShutdownGroup, + failureTerminationBehavior: .gracefullyShutdownGroup + ) + ], + logger: logger + ) + ) + } + + public func run() async throws { + try await serviceGroup.run() + } +} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift index 82996fb..986926b 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift @@ -21,7 +21,7 @@ struct BreezeLambdaHandler { typealias Event = APIGatewayV2Request typealias Output = APIGatewayV2Response - let service: BreezeDynamoDBServing + let service: BreezeDynamoDBManaging let operation: BreezeOperation var keyName: String { diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift new file mode 100644 index 0000000..999f95a --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -0,0 +1,36 @@ +// 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 ServiceLifecycle +import AsyncHTTPClient +import NIOCore +import BreezeDynamoDBService +import AWSLambdaRuntime + +actor BreezeLambdaService: Service { + + let dynamoDBService: BreezeDynamoDBService + + init(dynamoDBService: BreezeDynamoDBService) { + self.dynamoDBService = dynamoDBService + } + + func run() async throws { + let breezeApi = try await BreezeLambdaAPIHandler(service: dynamoDBService) + let runtime = LambdaRuntime { event, context in + try await breezeApi.handle(event, context: context) + } + try await runtime.run() + } +} diff --git a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift index dde98a2..0d26ba2 100644 --- a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift +++ b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift @@ -17,7 +17,7 @@ import Logging enum LocalStackDynamoDB { - static var endpoint: String = { + static let endpoint: String = { if let localstack = getEnvironmentVar(name: "LOCALSTACK_ENDPOINT"), !localstack.isEmpty { return localstack @@ -25,7 +25,7 @@ enum LocalStackDynamoDB { return "http://localhost:4566" }() - public static var logger: Logger = { + public static let logger: Logger = { if let loggingLevel = getEnvironmentVar(name: "AWS_LOG_LEVEL") { if let logLevel = Logger.Level(rawValue: loggingLevel.lowercased()) { var logger = Logger(label: "breeze") @@ -36,13 +36,13 @@ enum LocalStackDynamoDB { return AWSClient.loggingDisabled }() - static var client = AWSClient( + static let client = AWSClient( credentialProvider: .static(accessKeyId: "breeze", secretAccessKey: "magic"), middlewares: [AWSLoggingMiddleware()], httpClientProvider: .createNew ) - static var dynamoDB = DynamoDB( + static let dynamoDB = DynamoDB( client: client, region: .useast1, endpoint: endpoint diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift index c4758f6..a3e157a 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift @@ -16,7 +16,7 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB -struct BreezeDynamoDBServiceMock: BreezeDynamoDBServing { +struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { var keyName: String static var response: (any BreezeCodable)? diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift index 3e32005..9558f1a 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift @@ -39,7 +39,7 @@ final class BreezeLambdaAPITests: XCTestCase { unsetenv("DYNAMO_DB_TABLE_NAME") unsetenv("DYNAMO_DB_KEY") unsetenv("_HANDLER") - LambdaInitializationContext.DynamoDB.Service = BreezeDynamoDBService.self + LambdaInitializationContext.DynamoDB.Service = BreezeDynamoDBManager.self LambdaInitializationContext.DynamoDB.dbTimeout = 30 BreezeDynamoDBServiceMock.reset() try super.tearDownWithError() @@ -51,7 +51,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - _ = try await Lambda.test(BreezeLambdaAPI.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) } func test_initWhenMissing__HANDLER_thenThrowError() async throws { @@ -59,7 +59,7 @@ final class BreezeLambdaAPITests: XCTestCase { let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { - _ = try await Lambda.test(BreezeLambdaAPI.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) XCTFail("It should throw an Error when _HANDLER is missing") } catch BreezeLambdaAPIError.invalidHandler { XCTAssert(true) @@ -74,7 +74,7 @@ final class BreezeLambdaAPITests: XCTestCase { let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { - _ = try await Lambda.test(BreezeLambdaAPI.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) XCTFail("It should throw an Error when _HANDLER is invalid") } catch BreezeLambdaAPIError.invalidHandler { XCTAssert(true) @@ -90,7 +90,7 @@ final class BreezeLambdaAPITests: XCTestCase { let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { - _ = try await Lambda.test(BreezeLambdaAPI.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) XCTFail("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") } catch BreezeLambdaAPIError.tableNameNotFound { XCTAssert(true) @@ -106,7 +106,7 @@ final class BreezeLambdaAPITests: XCTestCase { let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { - _ = try await Lambda.test(BreezeLambdaAPI.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) XCTFail("It should throw an Error when DYNAMO_DB_KEY is missing") } catch BreezeLambdaAPIError.keyNameNotFound { XCTAssert(true) @@ -120,7 +120,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: Product = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .created) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -134,7 +134,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.response = nil let createRequest = try Fixtures.fixture(name: Fixtures.postInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .forbidden) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -146,7 +146,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.response = nil let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .forbidden) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -158,7 +158,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: Product = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .ok) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -172,7 +172,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .forbidden) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -184,7 +184,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .notFound) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -196,7 +196,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: Product = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .ok) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -210,7 +210,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .forbidden) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -222,7 +222,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .notFound) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -234,7 +234,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: BreezeEmptyResponse = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .ok) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -246,7 +246,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.productUdated2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: BreezeEmptyResponse = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .notFound) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -258,7 +258,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .forbidden) XCTAssertEqual(response.error, "invalidRequest") @@ -269,7 +269,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .notFound) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) @@ -281,7 +281,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.response = Fixtures.product2023 let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: ListResponse = try apiResponse.decodeBody() let item = try XCTUnwrap(response.items.first) XCTAssertEqual(BreezeDynamoDBServiceMock.limit, 1) @@ -298,7 +298,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBServiceMock.response = nil let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPI.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() XCTAssertEqual(apiResponse.statusCode, .forbidden) XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index 8ec17f5..9523989 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -18,38 +18,38 @@ import AWSLambdaRuntime import AWSLambdaTesting import Logging import NIO - -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() - } - 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) - } -} +// +//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() +// } +// 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) +// } +//} diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b0e9cb..6c4e734 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:5.7.3-amazonlinux2 as builder +FROM swift:6.0.1-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make From 3ded2ae7bfcd73e6cfe2af1008ed9ec8ecc0472f Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 21 Dec 2024 23:39:27 +0100 Subject: [PATCH 02/29] Add Package Resolved to avoid issue with SPM --- .gitignore | 2 +- Package.resolved | 231 ++++++++++++++++++ Package.swift | 17 +- .../BreezeDynamoDBService.swift | 20 +- .../BreezeLambdaAPIHandler.swift | 5 +- .../BreezeLambdaAPIService.swift | 11 + .../BreezeLambdaAPI/BreezeLambdaService.swift | 15 +- ...swift => BreezeDynamoDBManagerTests.swift} | 6 +- .../BreezeDynamoDBServiceMock.swift | 10 +- .../BreezeLambdaAPITests.swift | 7 +- Tests/BreezeLambdaAPITests/Lambda.swift | 70 +++--- 11 files changed, 326 insertions(+), 68 deletions(-) create mode 100644 Package.resolved rename Tests/BreezeDynamoDBServiceTests/{BreezeDynamoDBServiceTests.swift => BreezeDynamoDBManagerTests.swift} (98%) diff --git a/.gitignore b/.gitignore index 3e6760a..c9e8bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -Package.resolved +# Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..85c7c9d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,231 @@ +{ + "originHash" : "e94259b36a1b795646632d2a8700e806a6d19eaaaa907b0684d14dc614013e6f", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "2119f0d9cc1b334e25447fe43d3693c0e60e6234", + "version" : "1.24.0" + } + }, + { + "identity" : "jmespath.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/adam-fowler/jmespath.swift.git", + "state" : { + "revision" : "3877a5060e85ae33e3b9fe51ab581784f65ec80e", + "version" : "1.0.3" + } + }, + { + "identity" : "soto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/soto-project/soto.git", + "state" : { + "revision" : "c9afb020142858c23439ef247a7df330edc8f589", + "version" : "7.1.0" + } + }, + { + "identity" : "soto-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/soto-project/soto-core.git", + "state" : { + "revision" : "29848123812bd2624d2e2dc93a9b9009c2abe812", + "version" : "7.1.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "7faebca1ea4f9aaf0cda1cef7c43aecd2311ddf6", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-aws-lambda-events", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-aws-lambda-events.git", + "state" : { + "branch" : "main", + "revision" : "a61f77e02191de329e7f5a9bbefc50072661f9d7" + } + }, + { + "identity" : "swift-aws-lambda-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-aws-lambda-runtime.git", + "state" : { + "branch" : "main", + "revision" : "18660fcdd1cdaee43550dfa47df554b7e297702a" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "ff0f781cf7c6a22d52957e50b104f5768b50c779", + "version" : "3.10.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "6483d340853a944c96dbcc28b27dd10b6c581703", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", + "version" : "1.6.2" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "e0165b53d49b413dd987526b641e05e246782685", + "version" : "2.5.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "dca6594f65308c761a9c409e09fbf35f48d50d34", + "version" : "2.77.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d1ead62745cc3269e482f1c51f27608057174379", + "version" : "1.24.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "b5f7062b60e4add1e8c343ba4eb8da2e324b3a94", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "7b84abbdcef69cc3be6573ac12440220789dcd69", + "version" : "2.27.2" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "38ac8221dd20674682148d6451367f89c2652980", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "0c62c5b4601d6c125050b5c3a97f20cce881d32b", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "f70b838872863396a25694d8b19fe58bcd0b7903", + "version" : "2.6.2" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", + "version" : "1.4.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index baae143..e4f1609 100644 --- a/Package.swift +++ b/Package.swift @@ -1,13 +1,16 @@ // 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)] +#else +let platforms: [PackageDescription.SupportedPlatform]? = nil +#endif + let package = Package( name: "BreezeLambdaDynamoDBAPI", - platforms: [ - .macOS(.v15), - ], + platforms: platforms, products: [ .library( name: "BreezeDynamoDBService", @@ -30,9 +33,9 @@ let package = Package( .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", branch: "main"), .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), - .package(url: "https://github.com/soto-project/soto.git", from: "6.7.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.22.0"), + .package(url: "https://github.com/soto-project/soto.git", from: "7.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.24.0"), ], targets: [ .executableTarget( diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index a6a2b06..8df9cc8 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -19,7 +19,7 @@ import Logging public extension BreezeDynamoDBService { enum DynamoDB { - public static let Service: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self + nonisolated(unsafe) public static var Service: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self } } @@ -31,19 +31,27 @@ public actor BreezeDynamoDBService: Service { let region: Region let tableName: String let keyName: String + let endpoint: String? let logger: Logger - public init(httpClientService: BreezeHTTPClientService, region: Region, tableName: String, keyName: String, logger: Logger) { + public init( + httpClientService: BreezeHTTPClientService, + region: Region, + tableName: String, + keyName: String, + endpoint: String?, + logger: Logger + ) { self.httpClientService = httpClientService self.region = region self.tableName = tableName self.keyName = keyName + self.endpoint = endpoint self.logger = logger } } public var dbManager: BreezeDynamoDBManaging? - private var awsClient: AWSClient? private let config: Config public init(with config: Config) { @@ -53,8 +61,8 @@ public actor BreezeDynamoDBService: Service { public func run() async throws { config.logger.info("Starting DynamoDBService...") let httpClient = await config.httpClientService.httpClient - let awsClient = AWSClient(httpClientProvider: .shared(httpClient)) - let db = SotoDynamoDB.DynamoDB(client: awsClient, region: config.region) + let awsClient = AWSClient(httpClient: httpClient) + let db = SotoDynamoDB.DynamoDB(client: awsClient, region: config.region, endpoint: config.endpoint) self.dbManager = DynamoDB.Service.init( db: db, @@ -69,7 +77,7 @@ public actor BreezeDynamoDBService: Service { try await gracefulShutdown() config.logger.info("Shutting down DynamoDBService...") - try self.awsClient?.syncShutdown() + try await awsClient.shutdown() } } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift index 6430c93..315c690 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift @@ -20,9 +20,6 @@ import Foundation import SotoDynamoDB public struct BreezeLambdaAPIHandler: LambdaHandler, Sendable { - public typealias Event = APIGatewayV2Request - public typealias Output = APIGatewayV2Response - let service: BreezeDynamoDBService public init(service: BreezeDynamoDBService) async throws { @@ -38,7 +35,7 @@ public struct BreezeLambdaAPIHandler: LambdaHandler, Sendable return operation } - public func handle(_ event: AWSLambdaEvents.APIGatewayV2Request, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> AWSLambdaEvents.APIGatewayV2Response { + public func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { let operation = try Self.operation() context.logger.info("operation: \(operation)") guard let service = await service.dbManager else { diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift index 4d7f599..8c05dc1 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift @@ -50,6 +50,14 @@ public actor BreezeLambdaAPIService: Service { return tableName } + static func endpoint() -> String? { + if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), + !localstack.isEmpty { + return localstack + } + return nil + } + public init(dbTimeout: Int64 = 30) throws { self.timeout = .seconds(dbTimeout) self.httpClientService = BreezeHTTPClientService( @@ -61,6 +69,7 @@ public actor BreezeLambdaAPIService: Service { region: Self.currentRegion(), tableName: try Self.tableName(), keyName: try Self.keyName(), + endpoint: Self.endpoint(), logger: logger ) self.dynamoDBService = BreezeDynamoDBService(with: config) @@ -92,5 +101,7 @@ public actor BreezeLambdaAPIService: Service { public func run() async throws { try await serviceGroup.run() + + try await gracefulShutdown() } } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index 999f95a..cc9eea9 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -17,6 +17,7 @@ import AsyncHTTPClient import NIOCore import BreezeDynamoDBService import AWSLambdaRuntime +import AWSLambdaEvents actor BreezeLambdaService: Service { @@ -25,12 +26,18 @@ actor BreezeLambdaService: Service { init(dynamoDBService: BreezeDynamoDBService) { self.dynamoDBService = dynamoDBService } - + + var breezeApi: BreezeLambdaAPIHandler? + + func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + guard let breezeApi else { throw BreezeLambdaAPIError.invalidHandler } + return try await breezeApi.handle(event, context: context) + } + func run() async throws { let breezeApi = try await BreezeLambdaAPIHandler(service: dynamoDBService) - let runtime = LambdaRuntime { event, context in - try await breezeApi.handle(event, context: context) - } + self.breezeApi = breezeApi + let runtime = LambdaRuntime(body: handler) try await runtime.run() } } diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift similarity index 98% rename from Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift rename to Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift index ca0f32c..ff58c5f 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift @@ -25,11 +25,11 @@ struct Product: BreezeCodable { var updatedAt: String? } -final class BreezeDynamoDBServiceTests: XCTestCase { +final class BreezeDynamoDBManagerTests: XCTestCase { let tableName = "Breeze" let keyName = "key" - var sut: BreezeDynamoDBService! + var sut: BreezeDynamoDBManager! let product2023 = Product(key: "2023", name: "Swift Serverless API 2022", description: "Test") let product2022 = Product(key: "2022", name: "Swift Serverless API with async/await! 🚀🥳", description: "BreezeLambaAPI is magic 🪄!") @@ -38,7 +38,7 @@ final class BreezeDynamoDBServiceTests: XCTestCase { try await super.setUp() try await LocalStackDynamoDB.createTable(name: tableName, keyName: keyName) let db = LocalStackDynamoDB.dynamoDB - sut = BreezeDynamoDBService(db: db, tableName: tableName, keyName: keyName) + sut = BreezeDynamoDBManager(db: db, tableName: tableName, keyName: keyName) } override func tearDown() async throws { diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift index a3e157a..683e373 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift @@ -16,11 +16,13 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB + + struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { var keyName: String - static var response: (any BreezeCodable)? - static var keyedResponse: (any BreezeCodable)? + nonisolated(unsafe) static var response: (any BreezeCodable)? + nonisolated(unsafe) static var keyedResponse: (any BreezeCodable)? init(db: SotoDynamoDB.DynamoDB, tableName: String, keyName: String) { self.keyName = keyName @@ -62,8 +64,8 @@ struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { return } - static var limit: Int? - static var exclusiveKey: String? + nonisolated(unsafe) static var limit: Int? + nonisolated(unsafe) static var exclusiveKey: String? func listItems(key: String?, limit: Int?) async throws -> ListResponse { guard let response = Self.response as? T else { throw BreezeLambdaAPIError.invalidItem diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift index 9558f1a..e22bd64 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift @@ -29,8 +29,7 @@ final class BreezeLambdaAPITests: XCTestCase { setEnvironmentVar(name: "AWS_REGION", value: "eu-west-1", overwrite: true) setEnvironmentVar(name: "DYNAMO_DB_TABLE_NAME", value: "product-table", overwrite: true) setEnvironmentVar(name: "DYNAMO_DB_KEY", value: "sku", overwrite: true) - LambdaInitializationContext.DynamoDB.Service = BreezeDynamoDBServiceMock.self - LambdaInitializationContext.DynamoDB.dbTimeout = 1 + BreezeDynamoDBService.DynamoDB.Service = BreezeDynamoDBServiceMock.self } override func tearDownWithError() throws { @@ -39,8 +38,8 @@ final class BreezeLambdaAPITests: XCTestCase { unsetenv("DYNAMO_DB_TABLE_NAME") unsetenv("DYNAMO_DB_KEY") unsetenv("_HANDLER") - LambdaInitializationContext.DynamoDB.Service = BreezeDynamoDBManager.self - LambdaInitializationContext.DynamoDB.dbTimeout = 30 + BreezeDynamoDBService.DynamoDB.Service = BreezeDynamoDBManager.self +// BreezeDynamoDBService.DynamoDB.dbTimeout = 30 BreezeDynamoDBServiceMock.reset() try super.tearDownWithError() } diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index 9523989..668ce51 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -18,38 +18,38 @@ import AWSLambdaRuntime import AWSLambdaTesting import Logging import NIO -// -//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() -// } -// 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) -// } -//} + +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! await eventLoopGroup.shutdownGracefully() + } + 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) + } +} From 3c6ca26e85b987a9331206e774400e515c9fd80f Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sun, 22 Dec 2024 22:53:51 +0100 Subject: [PATCH 03/29] Add Unit Test --- Package.resolved | 6 +- Package.swift | 18 +- .../BreezeDemoApplication.swift | 13 +- .../BreezeDynamoDBService/BreezeCodable.swift | 4 + .../BreezeDynamoDBService.swift | 11 +- .../BreezeDynamoDBService/ListResponse.swift | 4 + .../BreezeHTTPClientService.swift | 9 +- .../BreezeLambdaAPIError.swift | 23 ++ .../BreezeLambdaAPIHandler.swift | 7 +- .../BreezeLambdaAPIService.swift | 84 +++--- .../BreezeLambdaAPI/BreezeLambdaService.swift | 21 +- .../BreezeDynamoDBManagerTests.swift | 180 ++++++++----- .../LocalStackDynamoDB.swift | 4 +- Tests/BreezeDynamoDBServiceTests/Utils.swift | 4 + .../BreezeHTTPClientServiceTests.swift | 48 ++++ .../APIGatewayV2Response.swift | 4 + .../BreezeDynamoDBServiceMock.swift | 2 - .../BreezeLambdaAPITests.swift | 239 ++++++++++++------ .../BreezeOperationTests.swift | 34 ++- Tests/BreezeLambdaAPITests/Fixtures.swift | 4 + Tests/BreezeLambdaAPITests/Lambda.swift | 135 +++++++--- Tests/BreezeLambdaAPITests/Product.swift | 4 + Tests/BreezeLambdaAPITests/Utils.swift | 5 +- 23 files changed, 621 insertions(+), 242 deletions(-) create mode 100644 Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift diff --git a/Package.resolved b/Package.resolved index 85c7c9d..782d3fe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e94259b36a1b795646632d2a8700e806a6d19eaaaa907b0684d14dc614013e6f", + "originHash" : "03296d2dcaccdb6b440525d8308a9dbf7ead03b7bc38c1fa9fbe70d640f7d469", "pins" : [ { "identity" : "async-http-client", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-aws-lambda-events.git", "state" : { - "branch" : "main", - "revision" : "a61f77e02191de329e7f5a9bbefc50072661f9d7" + "revision" : "cfd688e499894ed0ba527f1decf4dfc17ec06492", + "version" : "0.5.0" } }, { diff --git a/Package.swift b/Package.swift index e4f1609..4cea3f3 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,7 @@ let package = Package( ], dependencies: [ .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", 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/swift-service-lifecycle.git", from: "2.0.0"), .package(url: "https://github.com/soto-project/soto.git", from: "7.0.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), @@ -73,13 +73,27 @@ let package = Package( name: "BreezeLambdaAPITests", dependencies: [ .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "BreezeLambdaAPI" ], resources: [.copy("Fixtures")] ), .testTarget( name: "BreezeDynamoDBServiceTests", - dependencies: ["BreezeDynamoDBService"] + dependencies: [ + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), + "BreezeDynamoDBService" + ] + ), + .testTarget( + name: "BreezeHTTPClientServiceTests", + dependencies: [ + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), + "BreezeHTTPClientService" + ] ) ] ) diff --git a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift index 39d2508..37b0d12 100644 --- a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift +++ b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift @@ -1,9 +1,16 @@ +// Copyright 2023 (c) Andrea Scuderi - https://github.com/swift-serverless // -// BreezeDemoApplication.swift -// BreezeLambdaDynamoDBAPI +// 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 // -// Created by Andrea Scuderi on 21/12/2024. +// 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 BreezeLambdaAPI import BreezeDynamoDBService diff --git a/Sources/BreezeDynamoDBService/BreezeCodable.swift b/Sources/BreezeDynamoDBService/BreezeCodable.swift index 2dc3084..a58f39b 100644 --- a/Sources/BreezeDynamoDBService/BreezeCodable.swift +++ b/Sources/BreezeDynamoDBService/BreezeCodable.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 public protocol BreezeCodable: Codable, Sendable { var key: String { get set } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index 8df9cc8..7cd256a 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -62,14 +62,18 @@ public actor BreezeDynamoDBService: Service { config.logger.info("Starting DynamoDBService...") let httpClient = await config.httpClientService.httpClient let awsClient = AWSClient(httpClient: httpClient) - let db = SotoDynamoDB.DynamoDB(client: awsClient, region: config.region, endpoint: config.endpoint) - + let db = SotoDynamoDB.DynamoDB( + client: awsClient, + region: config.region, + endpoint: config.endpoint + ) self.dbManager = DynamoDB.Service.init( db: db, tableName: config.tableName, keyName: config.keyName ) - config.logger.info("DynamoDBService config...") + + config.logger.info("DynamoDBService is running with config...") config.logger.info("region: \(config.region)") config.logger.info("tableName: \(config.tableName)") config.logger.info("keyName: \(config.keyName)") @@ -78,6 +82,7 @@ public actor BreezeDynamoDBService: Service { config.logger.info("Shutting down DynamoDBService...") try await awsClient.shutdown() + config.logger.info("DynamoDBService is stopped.") } } diff --git a/Sources/BreezeDynamoDBService/ListResponse.swift b/Sources/BreezeDynamoDBService/ListResponse.swift index a1cfbd5..49879f2 100644 --- a/Sources/BreezeDynamoDBService/ListResponse.swift +++ b/Sources/BreezeDynamoDBService/ListResponse.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 public struct ListResponse: Codable { public init(items: [Item], lastEvaluatedKey: String? = nil) { diff --git a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift index 3fd95ba..6a16ee5 100644 --- a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift +++ b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift @@ -17,6 +17,10 @@ import AsyncHTTPClient import NIOCore import Logging +public protocol BreezeHTTPClientServing: Service { + var httpClient: HTTPClient { get } +} + public actor BreezeHTTPClientService: Service { public let httpClient: HTTPClient @@ -39,9 +43,12 @@ public actor BreezeHTTPClientService: Service { public func run() async throws { logger.info("HTTPClientService started...") - try? await gracefulShutdown() + try await gracefulShutdown() logger.info("Shutting down HTTPClientService...") try await httpClient.shutdown() + logger.info("HTTPClientService shutdown completed.") } } + +extension BreezeHTTPClientService: BreezeHTTPClientServing { } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift index cf7aaa6..a66a626 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.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 enum BreezeLambdaAPIError: Error { case invalidItem @@ -22,3 +26,22 @@ enum BreezeLambdaAPIError: Error { case invalidHandler case invalidService } + +extension BreezeLambdaAPIError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidItem: + return "Invalid Item" + case .tableNameNotFound: + return "Environment DYNAMO_DB_TABLE_NAME is not set" + case .keyNameNotFound: + return "Environment DYNAMO_DB_KEY is not set" + case .invalidRequest: + return "Invalid request" + case .invalidHandler: + return "Environment _HANDLER is invalid or missing" + case .invalidService: + return "Invalid Service" + } + } +} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift index 315c690..fd22324 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift @@ -16,9 +16,14 @@ import AsyncHTTPClient import AWSLambdaEvents import AWSLambdaRuntime import BreezeDynamoDBService -import Foundation import SotoDynamoDB +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + public struct BreezeLambdaAPIHandler: LambdaHandler, Sendable { let service: BreezeDynamoDBService diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift index 8c05dc1..a3dfa97 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift @@ -59,49 +59,59 @@ public actor BreezeLambdaAPIService: Service { } public init(dbTimeout: Int64 = 30) throws { - self.timeout = .seconds(dbTimeout) - self.httpClientService = BreezeHTTPClientService( - timeout: timeout, - logger: logger - ) - let config = BreezeDynamoDBService.Config( - httpClientService: httpClientService, - region: Self.currentRegion(), - tableName: try Self.tableName(), - keyName: try Self.keyName(), - endpoint: Self.endpoint(), - logger: logger - ) - self.dynamoDBService = BreezeDynamoDBService(with: config) - self.breezeLambdaService = BreezeLambdaService(dynamoDBService: dynamoDBService) - - self.serviceGroup = ServiceGroup( - configuration: .init( - services: [ - .init( - service: httpClientService, - successTerminationBehavior: .ignore, - failureTerminationBehavior: .gracefullyShutdownGroup - ), - .init( - service: dynamoDBService, - successTerminationBehavior: .gracefullyShutdownGroup, - failureTerminationBehavior: .gracefullyShutdownGroup - ), - .init( - service: breezeLambdaService, - successTerminationBehavior: .gracefullyShutdownGroup, - failureTerminationBehavior: .gracefullyShutdownGroup - ) - ], + do { + self.timeout = .seconds(dbTimeout) + self.httpClientService = BreezeHTTPClientService( + timeout: timeout, + logger: logger + ) + let config = BreezeDynamoDBService.Config( + httpClientService: httpClientService, + region: Self.currentRegion(), + tableName: try Self.tableName(), + keyName: try Self.keyName(), + endpoint: Self.endpoint(), logger: logger ) - ) + self.dynamoDBService = BreezeDynamoDBService(with: config) + self.breezeLambdaService = BreezeLambdaService( + dynamoDBService: dynamoDBService, + logger: logger + ) + + self.serviceGroup = ServiceGroup( + configuration: .init( + services: [ + .init( + service: httpClientService, + successTerminationBehavior: .ignore, + failureTerminationBehavior: .gracefullyShutdownGroup + ), + .init( + service: dynamoDBService, + successTerminationBehavior: .gracefullyShutdownGroup, + failureTerminationBehavior: .gracefullyShutdownGroup + ), + .init( + service: breezeLambdaService, + successTerminationBehavior: .gracefullyShutdownGroup, + failureTerminationBehavior: .gracefullyShutdownGroup + ) + ], + logger: logger + ) + ) + } catch { + logger.error("\(error.localizedDescription)") + fatalError(error.localizedDescription) + } } public func run() async throws { + logger.info("Starting BreezeLambdaAPIService...") try await serviceGroup.run() - + logger.info("Shutting down BreezeLambdaAPIService...") try await gracefulShutdown() + logger.info("BreezeLambdaAPIService is stopped.") } } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index cc9eea9..d4254f1 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -18,13 +18,16 @@ import NIOCore import BreezeDynamoDBService import AWSLambdaRuntime import AWSLambdaEvents +import Logging actor BreezeLambdaService: Service { let dynamoDBService: BreezeDynamoDBService + let logger: Logger - init(dynamoDBService: BreezeDynamoDBService) { + init(dynamoDBService: BreezeDynamoDBService, logger: Logger) { self.dynamoDBService = dynamoDBService + self.logger = logger } var breezeApi: BreezeLambdaAPIHandler? @@ -35,9 +38,17 @@ actor BreezeLambdaService: Service { } func run() async throws { - let breezeApi = try await BreezeLambdaAPIHandler(service: dynamoDBService) - self.breezeApi = breezeApi - let runtime = LambdaRuntime(body: handler) - try await runtime.run() + do { + logger.info("Initializing BreezeLambdaAPIHandler...") + let breezeApi = try await BreezeLambdaAPIHandler(service: dynamoDBService) + self.breezeApi = breezeApi + logger.info("Starting BreezeLambdaAPIHandler...") + let runtime = LambdaRuntime(body: handler) + try await runtime.run() + logger.info("BreezeLambdaAPIHandler stopped.") + } catch { + logger.error("\(error.localizedDescription)") + fatalError("\(error.localizedDescription)") + } } } diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift index ff58c5f..c681bfe 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift @@ -14,7 +14,12 @@ import SotoCore import SotoDynamoDB -import XCTest +import Testing +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif @testable import BreezeDynamoDBService struct Product: BreezeCodable { @@ -25,182 +30,229 @@ struct Product: BreezeCodable { var updatedAt: String? } -final class BreezeDynamoDBManagerTests: XCTestCase { +@Suite +struct BreezeDynamoDBManagerTests { - let tableName = "Breeze" let keyName = "key" - var sut: BreezeDynamoDBManager! let product2023 = Product(key: "2023", name: "Swift Serverless API 2022", description: "Test") let product2022 = Product(key: "2022", name: "Swift Serverless API with async/await! 🚀🥳", description: "BreezeLambaAPI is magic 🪄!") - override func setUp() async throws { - try await super.setUp() + func givenTable(tableName: String) async throws -> BreezeDynamoDBManager { try await LocalStackDynamoDB.createTable(name: tableName, keyName: keyName) let db = LocalStackDynamoDB.dynamoDB - sut = BreezeDynamoDBManager(db: db, tableName: tableName, keyName: keyName) + return BreezeDynamoDBManager(db: db, tableName: tableName, keyName: keyName) } - - override func tearDown() async throws { - sut = nil + + func removeTable(tableName: String) async throws { try await LocalStackDynamoDB.deleteTable(name: tableName) - try await super.tearDown() } + @Test func test_createItem() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) let value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, product2023.key) - XCTAssertEqual(value.name, product2023.name) - XCTAssertEqual(value.description, product2023.description) - XCTAssertNotNil(value.createdAt?.iso8601) - XCTAssertNotNil(value.updatedAt?.iso8601) + #expect(value.key == product2023.key) + #expect(value.name == product2023.name) + #expect(value.description == product2023.description) + try #require(value.createdAt?.iso8601 != nil) + try #require(value.updatedAt?.iso8601 != nil) + try await removeTable(tableName: uuid) } + @Test func test_createItemDuplicate_shouldThrowConditionalCheckFailedException() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) let value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, product2023.key) - XCTAssertEqual(value.name, product2023.name) - XCTAssertEqual(value.description, product2023.description) - XCTAssertNotNil(value.createdAt?.iso8601) - XCTAssertNotNil(value.updatedAt?.iso8601) + #expect(value.key == product2023.key) + #expect(value.name == product2023.name) + #expect(value.description == product2023.description) + try #require(value.createdAt?.iso8601 != nil) + try #require(value.updatedAt?.iso8601 != nil) do { _ = try await sut.createItem(item: product2023) - XCTFail("It should throw conditionalCheckFailedException") + Issue.record("It should throw conditionalCheckFailedException") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_readItem() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) let cretedItem = try await sut.createItem(item: product2023) let readedItem: Product = try await sut.readItem(key: "2023") - XCTAssertEqual(cretedItem.key, readedItem.key) - XCTAssertEqual(cretedItem.name, readedItem.name) - XCTAssertEqual(cretedItem.description, readedItem.description) - XCTAssertEqual(cretedItem.createdAt?.iso8601, readedItem.createdAt?.iso8601) - XCTAssertEqual(cretedItem.updatedAt?.iso8601, readedItem.updatedAt?.iso8601) + #expect(cretedItem.key == readedItem.key) + #expect(cretedItem.name == readedItem.name) + #expect(cretedItem.description == readedItem.description) + #expect(cretedItem.createdAt?.iso8601 == readedItem.createdAt?.iso8601) + #expect(cretedItem.updatedAt?.iso8601 == readedItem.updatedAt?.iso8601) + try await removeTable(tableName: uuid) } + @Test func test_readItem_whenItemIsMissing() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) let value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") + #expect(value.key == "2023") do { let _: Product = try await sut.readItem(key: "2022") - XCTFail("It should throw when Item is missing") + Issue.record("It should throw when Item is missing") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_updateItem() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) var value = try await sut.createItem(item: product2023) value.name = "New Name" value.description = "New Description" let newValue = try await sut.updateItem(item: value) - XCTAssertEqual(value.key, newValue.key) - XCTAssertEqual(value.name, newValue.name) - XCTAssertEqual(value.description, newValue.description) - XCTAssertEqual(value.createdAt?.iso8601, newValue.createdAt?.iso8601) - XCTAssertNotEqual(value.updatedAt?.iso8601, newValue.updatedAt?.iso8601) + #expect(value.key == newValue.key) + #expect(value.name == newValue.name) + #expect(value.description == newValue.description) + #expect(value.createdAt?.iso8601 == newValue.createdAt?.iso8601) + #expect(value.updatedAt?.iso8601 != newValue.updatedAt?.iso8601) + try await removeTable(tableName: uuid) } + @Test func test_updateItem_whenItemHasChanged_shouldThrowConditionalCheckFailedException() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) var value = try await sut.createItem(item: product2023) value.name = "New Name" value.description = "New Description" let newValue = try await sut.updateItem(item: value) - XCTAssertEqual(value.key, newValue.key) - XCTAssertEqual(value.name, newValue.name) - XCTAssertEqual(value.description, newValue.description) - XCTAssertEqual(value.createdAt?.iso8601, newValue.createdAt?.iso8601) - XCTAssertNotEqual(value.updatedAt?.iso8601, newValue.updatedAt?.iso8601) + #expect(value.key == newValue.key) + #expect(value.name == newValue.name) + #expect(value.description == newValue.description) + #expect(value.createdAt?.iso8601 == newValue.createdAt?.iso8601) + #expect(value.updatedAt?.iso8601 != newValue.updatedAt?.iso8601) do { let _: Product = try await sut.updateItem(item: product2023) - XCTFail("It should throw conditionalCheckFailedException") + Issue.record("It should throw conditionalCheckFailedException") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } do { let _: Product = try await sut.updateItem(item: product2022) - XCTFail("It should throw conditionalCheckFailedException") + Issue.record("It should throw conditionalCheckFailedException") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_deleteItem() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) let value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") + #expect(value.key == "2023") try await sut.deleteItem(item: value) let readedItem: Product? = try? await sut.readItem(key: "2023") - XCTAssertNil(readedItem) + #expect(readedItem == nil) + try await removeTable(tableName: uuid) } func test_deleteItem_whenItemIsMissing_thenShouldThrow() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) do { try await sut.deleteItem(item: product2022) - XCTFail("It should throw ServiceError.missingParameters") + Issue.record("It should throw ServiceError.missingParameters") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_deleteItem_whenMissingUpdatedAt_thenShouldThrow() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") + #expect(value.key == "2023") value.updatedAt = nil do { try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") + Issue.record("It should throw ServiceError.missingParameters") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_deleteItem_whenMissingCreatedAt_thenShouldThrow() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") + #expect(value.key == "2023") value.createdAt = nil do { try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") + Issue.record("It should throw ServiceError.missingParameters") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_deleteItem_whenOutdatedUpdatedAt_thenShouldThrow() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") + #expect(value.key == "2023") value.updatedAt = Date().iso8601 do { try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") + Issue.record("It should throw ServiceError.missingParameters") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_deleteItem_whenOutdatedCreatedAt_thenShouldThrow() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") + #expect(value.key == "2023") value.createdAt = Date().iso8601 do { try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") + Issue.record("It should throw ServiceError.missingParameters") } catch { - XCTAssertNotNil(error) + try #require(error != nil) } + try await removeTable(tableName: uuid) } + @Test func test_listItem() async throws { + let uuid = UUID().uuidString + let sut = try await givenTable(tableName: uuid) let value1 = try await sut.createItem(item: product2022) let value2 = try await sut.createItem(item: product2023) let list: ListResponse = try await sut.listItems(key: nil, limit: nil) - XCTAssertEqual(list.items.count, 2) + #expect(list.items.count == 2) let keys = Set(list.items.map { $0.key }) - XCTAssertTrue(keys.contains(value1.key)) - XCTAssertTrue(keys.contains(value2.key)) + #expect(keys.contains(value1.key)) + #expect(keys.contains(value2.key)) + try await removeTable(tableName: uuid) } } diff --git a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift index 0d26ba2..4c84e5c 100644 --- a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift +++ b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift @@ -38,8 +38,7 @@ enum LocalStackDynamoDB { static let client = AWSClient( credentialProvider: .static(accessKeyId: "breeze", secretAccessKey: "magic"), - middlewares: [AWSLoggingMiddleware()], - httpClientProvider: .createNew + middleware: AWSLoggingMiddleware() ) static let dynamoDB = DynamoDB( @@ -67,4 +66,3 @@ enum LocalStackDynamoDB { _ = try await Self.dynamoDB.deleteTable(input, logger: Self.logger) } } - diff --git a/Tests/BreezeDynamoDBServiceTests/Utils.swift b/Tests/BreezeDynamoDBServiceTests/Utils.swift index 822b27c..b36119c 100644 --- a/Tests/BreezeDynamoDBServiceTests/Utils.swift +++ b/Tests/BreezeDynamoDBServiceTests/Utils.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 func getEnvironmentVar(name: String) -> String? { guard let envValue = getenv(name) else { diff --git a/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift new file mode 100644 index 0000000..338afd7 --- /dev/null +++ b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift @@ -0,0 +1,48 @@ +// 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 BreezeHTTPClientService +import Logging +import Testing +import ServiceLifecycle +import ServiceLifecycleTestKit + +@Suite +struct BreezeHTTPClientServiceTests { + + let logger = Logger(label: "BreezeHTTPClientServiceTests") + + @Test + func test_breezeHTTPClientServiceGracefulShutdown() async throws { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + try await withThrowingTaskGroup(of: Void.self) { group in + let sut = BreezeHTTPClientService(timeout: .seconds(1), logger: logger) + group.addTask { + try await withGracefulShutdownHandler { + try await sut.run() + let httpClient = await sut.httpClient + #expect(httpClient != nil) + } onGracefulShutdown: { + logger.info("Performing onGracefulShutdown") + } + } + group.addTask { + try await Task.sleep(nanoseconds: 10_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + try await group.waitForAll() + } + } + } +} diff --git a/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift b/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift index 157dcf4..a9418ee 100644 --- a/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift +++ b/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift @@ -13,7 +13,11 @@ // limitations under the License. import AWSLambdaEvents +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif extension APIGatewayV2Response { func decodeBody() throws -> Out { diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift index 683e373..044731e 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift @@ -16,8 +16,6 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB - - struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { var keyName: String diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift index e22bd64..4410005 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift @@ -14,17 +14,27 @@ import AWSLambdaEvents import AWSLambdaRuntime -import AWSLambdaTesting +import ServiceLifecycle +import ServiceLifecycleTestKit import BreezeDynamoDBService +import BreezeHTTPClientService @testable import BreezeLambdaAPI -import XCTest +@testable import AWSLambdaRuntimeCore +import Testing +import Logging +import AsyncHTTPClient +import NIOCore +import Foundation -final class BreezeLambdaAPITests: XCTestCase { +@Suite +struct BreezeLambdaAPITests { let decoder = JSONDecoder() + let encoder = JSONEncoder() + + let logger = Logger(label: "BreezeLambdaAPITests") - override func setUpWithError() throws { - try super.setUpWithError() + func setUpWithError() throws { setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) setEnvironmentVar(name: "AWS_REGION", value: "eu-west-1", overwrite: true) setEnvironmentVar(name: "DYNAMO_DB_TABLE_NAME", value: "product-table", overwrite: true) @@ -32,7 +42,7 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBService.DynamoDB.Service = BreezeDynamoDBServiceMock.self } - override func tearDownWithError() throws { + func tearDownWithError() throws { unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") unsetenv("AWS_REGION") unsetenv("DYNAMO_DB_TABLE_NAME") @@ -41,48 +51,58 @@ final class BreezeLambdaAPITests: XCTestCase { BreezeDynamoDBService.DynamoDB.Service = BreezeDynamoDBManager.self // BreezeDynamoDBService.DynamoDB.dbTimeout = 30 BreezeDynamoDBServiceMock.reset() - try super.tearDownWithError() } + @Test func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { + try setUpWithError() unsetenv("AWS_REGION") setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) BreezeDynamoDBServiceMock.response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + try tearDownWithError() } + @Test func test_initWhenMissing__HANDLER_thenThrowError() async throws { + try setUpWithError() BreezeDynamoDBServiceMock.response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - XCTFail("It should throw an Error when _HANDLER is missing") + Issue.record("It should throw an Error when _HANDLER is missing") } catch BreezeLambdaAPIError.invalidHandler { - XCTAssert(true) + #expect(true) } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.invalidHandler") + Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") } + try tearDownWithError() } + @Test func test_initWhenInvalid__HANDLER_thenThrowError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.c", overwrite: true) BreezeDynamoDBServiceMock.response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - XCTFail("It should throw an Error when _HANDLER is invalid") + Issue.record("It should throw an Error when _HANDLER is invalid") } catch BreezeLambdaAPIError.invalidHandler { - XCTAssert(true) + #expect(true) } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.invalidHandler") + Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") } + try tearDownWithError() } + @Test func test_initWhenMissing_DYNAMO_DB_TABLE_NAME_thenThrowError() async throws { + try setUpWithError() unsetenv("DYNAMO_DB_TABLE_NAME") setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) BreezeDynamoDBServiceMock.response = Fixtures.product2023 @@ -90,15 +110,18 @@ final class BreezeLambdaAPITests: XCTestCase { let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - XCTFail("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") + Issue.record("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") } catch BreezeLambdaAPIError.tableNameNotFound { - XCTAssert(true) + #expect(true) } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.tableNameNotFound") + Issue.record("Is should throw an BreezeLambdaAPIError.tableNameNotFound") } + try tearDownWithError() } + @Test func test_initWhenMissing_DYNAMO_DB_KEY_thenThrowError() async throws { + try setUpWithError() unsetenv("DYNAMO_DB_KEY") setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) BreezeDynamoDBServiceMock.response = Fixtures.product2023 @@ -106,201 +129,267 @@ final class BreezeLambdaAPITests: XCTestCase { let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - XCTFail("It should throw an Error when DYNAMO_DB_KEY is missing") + Issue.record("It should throw an Error when DYNAMO_DB_KEY is missing") } catch BreezeLambdaAPIError.keyNameNotFound { - XCTAssert(true) + #expect(true) } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.keyNameNotFound") + Issue.record("Is should throw an BreezeLambdaAPIError.keyNameNotFound") } + try tearDownWithError() } + @Test func test_create() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) BreezeDynamoDBServiceMock.response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: Product = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .created) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.key, "2023") - XCTAssertEqual(response.name, "Swift Serverless API with async/await! 🚀🥳") - XCTAssertEqual(response.description, "BreezeLambaAPI is magic 🪄!") + #expect(apiResponse.statusCode == .created) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.key == "2023") + #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(response.description == "BreezeLambaAPI is magic 🪄!") + try tearDownWithError() } + @Test func test_create_whenInvalidItem_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) BreezeDynamoDBServiceMock.response = nil let createRequest = try Fixtures.fixture(name: Fixtures.postInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .forbidden) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_create_whenMissingItem_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) BreezeDynamoDBServiceMock.response = nil let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .forbidden) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_read() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: Product = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.key, "2023") - XCTAssertEqual(response.name, "Swift Serverless API with async/await! 🚀🥳") - XCTAssertEqual(response.description, "BreezeLambaAPI is magic 🪄!") + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.key == "2023") + #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(response.description == "BreezeLambaAPI is magic 🪄!") + try tearDownWithError() } + @Test func test_read_whenInvalidRequest_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .forbidden) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_read_whenMissingItem_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .notFound) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .notFound) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_update() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: Product = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.key, "2023") - XCTAssertEqual(response.name, "Swift Serverless API with async/await! 🚀🥳") - XCTAssertEqual(response.description, "BreezeLambaAPI is magic 🪄!") + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.key == "2023") + #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(response.description == "BreezeLambaAPI is magic 🪄!") + try tearDownWithError() } + @Test func test_update_whenInvalidRequest_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .forbidden) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_update_whenMissingItem_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .notFound) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .notFound) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_delete() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: BreezeEmptyResponse = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertNotNil(response) + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response != nil) + try tearDownWithError() } + @Test func test_delete_whenRequestIsOutaded() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.productUdated2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: BreezeEmptyResponse = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .notFound) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertNotNil(response) + #expect(apiResponse.statusCode == .notFound) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response != nil) + try tearDownWithError() } + @Test func test_delete_whenInvalidRequest_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .forbidden) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_delete_whenMissingItem_thenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .notFound) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidRequest") + #expect(apiResponse.statusCode == .notFound) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidRequest") + try tearDownWithError() } + @Test func test_list() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.list", overwrite: true) BreezeDynamoDBServiceMock.response = Fixtures.product2023 let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: ListResponse = try apiResponse.decodeBody() - let item = try XCTUnwrap(response.items.first) - XCTAssertEqual(BreezeDynamoDBServiceMock.limit, 1) - XCTAssertEqual(BreezeDynamoDBServiceMock.exclusiveKey, "2023") - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(item.key, "2023") - XCTAssertEqual(item.name, "Swift Serverless API with async/await! 🚀🥳") - XCTAssertEqual(item.description, "BreezeLambaAPI is magic 🪄!") + let item = try #require(response.items.first) + #expect(BreezeDynamoDBServiceMock.limit == 1) + #expect(BreezeDynamoDBServiceMock.exclusiveKey == "2023") + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(item.key == "2023") + #expect(item.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(item.description == "BreezeLambaAPI is magic 🪄!") + try tearDownWithError() } + @Test func test_list_whenError() async throws { + try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.list", overwrite: true) BreezeDynamoDBServiceMock.response = nil let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertEqual(response.error, "invalidItem") + #expect(apiResponse.statusCode == .forbidden) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + #expect(response.error == "invalidItem") + try tearDownWithError() + } +} + +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/BreezeLambdaAPITests/BreezeOperationTests.swift b/Tests/BreezeLambdaAPITests/BreezeOperationTests.swift index 429f1db..6ca2e59 100644 --- a/Tests/BreezeLambdaAPITests/BreezeOperationTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeOperationTests.swift @@ -12,33 +12,43 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation -import XCTest +#endif +import Testing @testable import BreezeLambdaAPI -final class BreezeOperationTests: XCTestCase { +@Suite +struct BreezeOperationTests { + @Test func test_createOperation() { - XCTAssertEqual(BreezeOperation(handler: "build/Products.create"), BreezeOperation.create) - XCTAssertEqual(BreezeOperation(handler: "create"), BreezeOperation.create) + #expect(BreezeOperation(handler: "build/Products.create") == BreezeOperation.create) + #expect(BreezeOperation(handler: "create") == BreezeOperation.create) } + @Test func test_readOperation() { - XCTAssertEqual(BreezeOperation(handler: "build/Products.read"), BreezeOperation.read) - XCTAssertEqual(BreezeOperation(handler: "read"), BreezeOperation.read) + #expect(BreezeOperation(handler: "build/Products.read") == BreezeOperation.read) + #expect(BreezeOperation(handler: "read") == BreezeOperation.read) } + @Test func test_updateOperation() { - XCTAssertEqual(BreezeOperation(handler: "build/Products.update"), BreezeOperation.update) - XCTAssertEqual(BreezeOperation(handler: "update"), BreezeOperation.update) + #expect(BreezeOperation(handler: "build/Products.update") == BreezeOperation.update) + #expect(BreezeOperation(handler: "update") == BreezeOperation.update) } + @Test func test_deleteOperation() { - XCTAssertEqual(BreezeOperation(handler: "build/Products.delete"), BreezeOperation.delete) - XCTAssertEqual(BreezeOperation(handler: "delete"), BreezeOperation.delete) + #expect(BreezeOperation(handler: "build/Products.delete") == BreezeOperation.delete) + #expect(BreezeOperation(handler: "delete") == BreezeOperation.delete) } + @Test func test_listOperation() { - XCTAssertEqual(BreezeOperation(handler: "build/Products.list"), BreezeOperation.list) - XCTAssertEqual(BreezeOperation(handler: "list"), BreezeOperation.list) + #expect(BreezeOperation(handler: "build/Products.list") == BreezeOperation.list) + #expect(BreezeOperation(handler: "list") == BreezeOperation.list) } } diff --git a/Tests/BreezeLambdaAPITests/Fixtures.swift b/Tests/BreezeLambdaAPITests/Fixtures.swift index b78298e..ea7acf0 100644 --- a/Tests/BreezeLambdaAPITests/Fixtures.swift +++ b/Tests/BreezeLambdaAPITests/Fixtures.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 BreezeLambdaAPI import AWSLambdaEvents diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index 668ce51..6fe4a91 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -14,42 +14,117 @@ import AWSLambdaEvents import AWSLambdaRuntime +import BreezeDynamoDBService +import BreezeHTTPClientService +@testable import BreezeLambdaAPI @testable import AWSLambdaRuntimeCore import AWSLambdaTesting import Logging import NIO +import ServiceLifecycle +import ServiceLifecycleTestKit +import Foundation +import Logging +import Testing 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! await eventLoopGroup.shutdownGracefully() - } - 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() + + enum TestState { + case none + case running + case result(BreezeLambdaAPIHandler.Output) + } + + static func test( + _ handlerType: BreezeLambdaAPIHandler.Type, + with event: BreezeLambdaAPIHandler.Event) async throws -> BreezeLambdaAPIHandler.Output { + + let logger = Logger(label: "evaluateHandler") + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + return try await testGracefulShutdown { gracefulShutdownTestTrigger in + let httpClientService = BreezeHTTPClientService(timeout: .seconds(1), logger: logger) + let config = BreezeDynamoDBService.Config( + httpClientService: httpClientService, + region: .useast1, + tableName: "Breeze", + keyName: "key", + endpoint: nil, + logger: logger) + let dynamoDBService = BreezeDynamoDBService(with: config) + let sut = try await handlerType.init(service: dynamoDBService) + + let serviceGroup = ServiceGroup( + configuration: .init( + services: [ + .init( + service: httpClientService, + successTerminationBehavior: .ignore, + failureTerminationBehavior: .gracefullyShutdownGroup + ), + .init( + service: dynamoDBService, + successTerminationBehavior: .gracefullyShutdownGroup, + failureTerminationBehavior: .gracefullyShutdownGroup + ) + ], + logger: logger + ) + ) + + let testState = try await withThrowingTaskGroup(of: TestState.self) { group in + group.addTask { + try await serviceGroup.run() + return TestState.running + } + + group.addTask { + defer { + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + let closureHandler = ClosureHandler { event, context in + 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() + return TestState.result(try decoder.decode(BreezeLambdaAPIHandler.Output.self, from: result)) + } + for try await value in group { + switch value { + case .none, .running: + break + case .result: + return value + } + } + return TestState.none + } + + switch testState { + case .none, .running: + return APIGatewayV2Response(with: "", statusCode: .noContent) + case .result(let response): + return response + } } - return try await handler.handle(event, context: context) } } diff --git a/Tests/BreezeLambdaAPITests/Product.swift b/Tests/BreezeLambdaAPITests/Product.swift index b45e64c..51fa965 100644 --- a/Tests/BreezeLambdaAPITests/Product.swift +++ b/Tests/BreezeLambdaAPITests/Product.swift @@ -13,7 +13,11 @@ // limitations under the License. import BreezeDynamoDBService +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif struct Product: BreezeCodable { var key: String diff --git a/Tests/BreezeLambdaAPITests/Utils.swift b/Tests/BreezeLambdaAPITests/Utils.swift index d98e67c..5e97928 100644 --- a/Tests/BreezeLambdaAPITests/Utils.swift +++ b/Tests/BreezeLambdaAPITests/Utils.swift @@ -12,8 +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 func setEnvironmentVar(name: String, value: String, overwrite: Bool) { setenv(name, value, overwrite ? 1 : 0) } From fb51990dc982dea989420893eb9aad594fc04fbf Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Mon, 23 Dec 2024 15:15:17 +0100 Subject: [PATCH 04/29] Fix Unit Tests --- Package.swift | 2 +- .../BreezeDynamoDBService/BreezeCodable.swift | 4 +- .../BreezeDynamoDBConfig.swift | 34 +++ .../BreezeDynamoDBService.swift | 76 +++-- .../BreezeDynamoDBService/ListResponse.swift | 3 +- .../BreezeClientServiceConfig.swift | 29 ++ .../BreezeHTTPClientService.swift | 9 +- .../BreezeLambdaAPIHandler.swift | 4 +- .../BreezeLambdaAPIService.swift | 15 +- .../BreezeLambdaAPI/BreezeLambdaService.swift | 4 +- .../BreezeDynamoDBServiceMock.swift | 38 ++- .../BreezeLambdaAPITests.swift | 274 ++++++++---------- Tests/BreezeLambdaAPITests/Lambda.swift | 19 +- 13 files changed, 266 insertions(+), 245 deletions(-) create mode 100644 Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift create mode 100644 Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift diff --git a/Package.swift b/Package.swift index 4cea3f3..f1e1ffd 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription #if os(macOS) -let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15)] +let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15), .iOS(.v13)] #else let platforms: [PackageDescription.SupportedPlatform]? = nil #endif diff --git a/Sources/BreezeDynamoDBService/BreezeCodable.swift b/Sources/BreezeDynamoDBService/BreezeCodable.swift index a58f39b..4c49ddc 100644 --- a/Sources/BreezeDynamoDBService/BreezeCodable.swift +++ b/Sources/BreezeDynamoDBService/BreezeCodable.swift @@ -18,7 +18,9 @@ import FoundationEssentials import Foundation #endif -public protocol BreezeCodable: Codable, Sendable { +public protocol CodableSendable: Sendable, Codable { } + +public protocol BreezeCodable: CodableSendable { var key: String { get set } var createdAt: String? { get set } var updatedAt: String? { get set } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift new file mode 100644 index 0000000..dad1207 --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift @@ -0,0 +1,34 @@ +// 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 SotoCore + +public struct BreezeDynamoDBConfig: Sendable { + public init( + region: Region, + tableName: String, + keyName: String, + endpoint: String? = nil + ) { + self.region = region + self.tableName = tableName + self.keyName = keyName + self.endpoint = endpoint + } + + let region: Region + let tableName: String + let keyName: String + let endpoint: String? +} diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index 7cd256a..1d73b80 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -17,72 +17,64 @@ import ServiceLifecycle import BreezeHTTPClientService import Logging -public extension BreezeDynamoDBService { - enum DynamoDB { - nonisolated(unsafe) public static var Service: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self - } +public protocol BreezeDynamoDBServing: Actor, Service { + var dbManager: BreezeDynamoDBManaging? { get } } -public actor BreezeDynamoDBService: Service { - - public struct Config: Sendable { - - let httpClientService: BreezeHTTPClientService - let region: Region - let tableName: String - let keyName: String - let endpoint: String? - let logger: Logger - - public init( - httpClientService: BreezeHTTPClientService, - region: Region, - tableName: String, - keyName: String, - endpoint: String?, - logger: Logger - ) { - self.httpClientService = httpClientService - self.region = region - self.tableName = tableName - self.keyName = keyName - self.endpoint = endpoint - self.logger = logger - } - } +public actor BreezeDynamoDBService: BreezeDynamoDBServing { public var dbManager: BreezeDynamoDBManaging? - private let config: Config + private let config: BreezeDynamoDBConfig + private let serviceConfig: BreezeClientServiceConfig + private let DBManagingType: BreezeDynamoDBManaging.Type - public init(with config: Config) { + public init( + config: BreezeDynamoDBConfig, + serviceConfig: BreezeClientServiceConfig, + DBManagingType: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self + ) { self.config = config + self.serviceConfig = serviceConfig + self.DBManagingType = DBManagingType + } + + private var awsClient: AWSClient? + + private var logger: Logger { + serviceConfig.logger } public func run() async throws { - config.logger.info("Starting DynamoDBService...") - let httpClient = await config.httpClientService.httpClient + logger.info("Starting DynamoDBService...") + let httpClient = await serviceConfig.httpClientService.httpClient let awsClient = AWSClient(httpClient: httpClient) + self.awsClient = awsClient let db = SotoDynamoDB.DynamoDB( client: awsClient, region: config.region, endpoint: config.endpoint ) - self.dbManager = DynamoDB.Service.init( + self.dbManager = DBManagingType.init( db: db, tableName: config.tableName, keyName: config.keyName ) - config.logger.info("DynamoDBService is running with config...") - config.logger.info("region: \(config.region)") - config.logger.info("tableName: \(config.tableName)") - config.logger.info("keyName: \(config.keyName)") + logger.info("DynamoDBService is running with config...") + logger.info("region: \(config.region)") + logger.info("tableName: \(config.tableName)") + logger.info("keyName: \(config.keyName)") try await gracefulShutdown() - config.logger.info("Shutting down DynamoDBService...") + logger.info("Shutting down DynamoDBService...") try await awsClient.shutdown() - config.logger.info("DynamoDBService is stopped.") + self.awsClient = nil + logger.info("DynamoDBService is stopped.") + } + + deinit { + try? awsClient?.syncShutdown() } } diff --git a/Sources/BreezeDynamoDBService/ListResponse.swift b/Sources/BreezeDynamoDBService/ListResponse.swift index 49879f2..307b7f0 100644 --- a/Sources/BreezeDynamoDBService/ListResponse.swift +++ b/Sources/BreezeDynamoDBService/ListResponse.swift @@ -18,12 +18,11 @@ import FoundationEssentials import Foundation #endif -public struct ListResponse: Codable { +public struct ListResponse: CodableSendable { public init(items: [Item], lastEvaluatedKey: String? = nil) { self.items = items self.lastEvaluatedKey = lastEvaluatedKey } - public let items: [Item] public let lastEvaluatedKey: String? } diff --git a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift new file mode 100644 index 0000000..d7b11ed --- /dev/null +++ b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift @@ -0,0 +1,29 @@ +// 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 Logging + +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 index 6a16ee5..fff5d77 100644 --- a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift +++ b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift @@ -17,11 +17,11 @@ import AsyncHTTPClient import NIOCore import Logging -public protocol BreezeHTTPClientServing: Service { +public protocol BreezeHTTPClientServing: Actor, Service { var httpClient: HTTPClient { get } } -public actor BreezeHTTPClientService: Service { +public actor BreezeHTTPClientService: BreezeHTTPClientServing { public let httpClient: HTTPClient let logger: Logger @@ -49,6 +49,9 @@ public actor BreezeHTTPClientService: Service { try await httpClient.shutdown() logger.info("HTTPClientService shutdown completed.") } + + deinit { + try? httpClient.syncShutdown() + } } -extension BreezeHTTPClientService: BreezeHTTPClientServing { } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift index fd22324..b01b11b 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift @@ -25,9 +25,9 @@ import Foundation #endif public struct BreezeLambdaAPIHandler: LambdaHandler, Sendable { - let service: BreezeDynamoDBService + let service: BreezeDynamoDBServing - public init(service: BreezeDynamoDBService) async throws { + public init(service: BreezeDynamoDBServing) async throws { self.service = service } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift index a3dfa97..3e50c93 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift @@ -22,8 +22,8 @@ public actor BreezeLambdaAPIService: Service { let logger = Logger(label: "service-group") let timeout: TimeAmount - let httpClientService: BreezeHTTPClientService - let dynamoDBService: BreezeDynamoDBService + let httpClientService: BreezeHTTPClientServing + let dynamoDBService: BreezeDynamoDBServing let breezeLambdaService: BreezeLambdaService private let serviceGroup: ServiceGroup @@ -65,20 +65,21 @@ public actor BreezeLambdaAPIService: Service { timeout: timeout, logger: logger ) - let config = BreezeDynamoDBService.Config( - httpClientService: httpClientService, + let config = BreezeDynamoDBConfig( region: Self.currentRegion(), tableName: try Self.tableName(), keyName: try Self.keyName(), - endpoint: Self.endpoint(), + endpoint: Self.endpoint() + ) + let serviceConfig = BreezeClientServiceConfig( + httpClientService: httpClientService, logger: logger ) - self.dynamoDBService = BreezeDynamoDBService(with: config) + self.dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig) self.breezeLambdaService = BreezeLambdaService( dynamoDBService: dynamoDBService, logger: logger ) - self.serviceGroup = ServiceGroup( configuration: .init( services: [ diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index d4254f1..95cda02 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -22,10 +22,10 @@ import Logging actor BreezeLambdaService: Service { - let dynamoDBService: BreezeDynamoDBService + let dynamoDBService: BreezeDynamoDBServing let logger: Logger - init(dynamoDBService: BreezeDynamoDBService, logger: Logger) { + init(dynamoDBService: BreezeDynamoDBServing, logger: Logger) { self.dynamoDBService = dynamoDBService self.logger = logger } diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift index 044731e..c2f5ce9 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift @@ -16,25 +16,30 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB -struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { - var keyName: String +actor BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { + let keyName: String - nonisolated(unsafe) static var response: (any BreezeCodable)? - nonisolated(unsafe) static var keyedResponse: (any BreezeCodable)? + private var response: (any BreezeCodable)? + private var keyedResponse: (any BreezeCodable)? + + func setupMockResponse(response: (any BreezeCodable)?, keyedResponse: (any BreezeCodable)?) { + self.keyedResponse = keyedResponse + self.response = response + } init(db: SotoDynamoDB.DynamoDB, tableName: String, keyName: String) { self.keyName = keyName } func createItem(item: T) async throws -> T { - guard let response = Self.response as? T else { + guard let response = self.response as? T else { throw BreezeLambdaAPIError.invalidRequest } return response } func readItem(key: String) async throws -> T { - guard let response = Self.keyedResponse as? T, + guard let response = self.keyedResponse as? T, response.key == key else { throw BreezeLambdaAPIError.invalidRequest @@ -43,7 +48,7 @@ struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { } func updateItem(item: T) async throws -> T { - guard let response = Self.keyedResponse as? T, + guard let response = self.keyedResponse as? T, response.key == item.key else { throw BreezeLambdaAPIError.invalidRequest @@ -52,7 +57,7 @@ struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { } func deleteItem(item: T) async throws { - guard let response = Self.keyedResponse, + guard let response = self.keyedResponse, response.key == item.key, response.createdAt == item.createdAt, response.updatedAt == item.updatedAt @@ -62,21 +67,14 @@ struct BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { return } - nonisolated(unsafe) static var limit: Int? - nonisolated(unsafe) static var exclusiveKey: String? + var limit: Int? + var exclusiveKey: String? func listItems(key: String?, limit: Int?) async throws -> ListResponse { - guard let response = Self.response as? T else { + guard let response = self.response as? T else { throw BreezeLambdaAPIError.invalidItem } - Self.limit = limit - Self.exclusiveKey = key + self.limit = limit + self.exclusiveKey = key return ListResponse(items: [response], lastEvaluatedKey: key) } - - static func reset() { - Self.limit = nil - Self.exclusiveKey = nil - Self.response = nil - Self.keyedResponse = nil - } } diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift index 4410005..f77c5b2 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift @@ -26,6 +26,7 @@ import AsyncHTTPClient import NIOCore import Foundation + @Suite struct BreezeLambdaAPITests { @@ -33,344 +34,301 @@ struct BreezeLambdaAPITests { let encoder = JSONEncoder() let logger = Logger(label: "BreezeLambdaAPITests") - - func setUpWithError() throws { - setEnvironmentVar(name: "LOCAL_LAMBDA_SERVER_ENABLED", value: "true", overwrite: true) - setEnvironmentVar(name: "AWS_REGION", value: "eu-west-1", overwrite: true) - setEnvironmentVar(name: "DYNAMO_DB_TABLE_NAME", value: "product-table", overwrite: true) - setEnvironmentVar(name: "DYNAMO_DB_KEY", value: "sku", overwrite: true) - BreezeDynamoDBService.DynamoDB.Service = BreezeDynamoDBServiceMock.self - } - - func tearDownWithError() throws { - unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") - unsetenv("AWS_REGION") - unsetenv("DYNAMO_DB_TABLE_NAME") - unsetenv("DYNAMO_DB_KEY") - unsetenv("_HANDLER") - BreezeDynamoDBService.DynamoDB.Service = BreezeDynamoDBManager.self -// BreezeDynamoDBService.DynamoDB.dbTimeout = 30 - BreezeDynamoDBServiceMock.reset() - } + + let config = BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "sku") @Test - func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { - try setUpWithError() - unsetenv("AWS_REGION") - setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) - BreezeDynamoDBServiceMock.response = Fixtures.product2023 - let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - try tearDownWithError() + func testSerially() async throws { + try await test_initWhenMissing__HANDLER_thenThrowError() + try await test_initWhenInvalid__HANDLER_thenThrowError() + + try await test_create() + try await test_create_whenInvalidItem_thenError() + try await test_create_whenMissingItem_thenError() + + try await test_read() + try await test_read_whenInvalidRequest_thenError() + try await test_read_whenMissingItem_thenError() + + try await test_update() + try await test_update_whenInvalidRequest_thenError() + try await test_update_whenMissingItem_thenError() + + try await test_delete() + try await test_delete_whenRequestIsOutaded() + try await test_delete_whenInvalidRequest_thenError() + try await test_delete_whenMissingItem_thenError() + + try await test_list() + try await test_list_whenError() } - @Test +// @Test +// func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { +// try setUpWithError() +//// unsetenv("AWS_REGION") +// setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) +// let response = Fixtures.product2023 +// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") +// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) +// try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) +// try tearDownWithError() +// } + func test_initWhenMissing__HANDLER_thenThrowError() async throws { - try setUpWithError() - BreezeDynamoDBServiceMock.response = Fixtures.product2023 + let response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) Issue.record("It should throw an Error when _HANDLER is missing") } catch BreezeLambdaAPIError.invalidHandler { #expect(true) } catch { Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") } - try tearDownWithError() } - @Test func test_initWhenInvalid__HANDLER_thenThrowError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.c", overwrite: true) - BreezeDynamoDBServiceMock.response = Fixtures.product2023 + let response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) do { - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) Issue.record("It should throw an Error when _HANDLER is invalid") } catch BreezeLambdaAPIError.invalidHandler { #expect(true) } catch { Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") } - try tearDownWithError() } - @Test - func test_initWhenMissing_DYNAMO_DB_TABLE_NAME_thenThrowError() async throws { - try setUpWithError() - unsetenv("DYNAMO_DB_TABLE_NAME") - setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) - BreezeDynamoDBServiceMock.response = Fixtures.product2023 - let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - do { - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - Issue.record("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") - } catch BreezeLambdaAPIError.tableNameNotFound { - #expect(true) - } catch { - Issue.record("Is should throw an BreezeLambdaAPIError.tableNameNotFound") - } - try tearDownWithError() - } +// @Test +// func test_initWhenMissing_DYNAMO_DB_TABLE_NAME_thenThrowError() async throws { +// try setUpWithError() +// unsetenv("DYNAMO_DB_TABLE_NAME") +// setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) +// BreezeDynamoDBServiceMock.response = Fixtures.product2023 +// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") +// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) +// do { +// let value = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, with: request) +// Issue.record("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") +// } catch BreezeLambdaAPIError.tableNameNotFound { +// #expect(true) +// } catch { +// Issue.record("Is should throw an BreezeLambdaAPIError.tableNameNotFound") +// } +// try tearDownWithError() +// } - @Test - func test_initWhenMissing_DYNAMO_DB_KEY_thenThrowError() async throws { - try setUpWithError() - unsetenv("DYNAMO_DB_KEY") - setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) - BreezeDynamoDBServiceMock.response = Fixtures.product2023 - let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - do { - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - Issue.record("It should throw an Error when DYNAMO_DB_KEY is missing") - } catch BreezeLambdaAPIError.keyNameNotFound { - #expect(true) - } catch { - Issue.record("Is should throw an BreezeLambdaAPIError.keyNameNotFound") - } - try tearDownWithError() - } +// @Test +// func test_initWhenMissing_DYNAMO_DB_KEY_thenThrowError() async throws { +// try setUpWithError() +// unsetenv("DYNAMO_DB_KEY") +// setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) +// BreezeDynamoDBServiceMock.response = Fixtures.product2023 +// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") +// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) +// do { +// _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, with: request) +// Issue.record("It should throw an Error when DYNAMO_DB_KEY is missing") +// } catch BreezeLambdaAPIError.keyNameNotFound { +// #expect(true) +// } catch { +// Issue.record("Is should throw an BreezeLambdaAPIError.keyNameNotFound") +// } +// try tearDownWithError() +// } - @Test func test_create() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) - BreezeDynamoDBServiceMock.response = Fixtures.product2023 + let response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - let response: Product = try apiResponse.decodeBody() + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) + let product: Product = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .created) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response.key == "2023") - #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") - #expect(response.description == "BreezeLambaAPI is magic 🪄!") - try tearDownWithError() + #expect(product.key == "2023") + #expect(product.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(product.description == "BreezeLambaAPI is magic 🪄!") } - @Test func test_create_whenInvalidItem_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) - BreezeDynamoDBServiceMock.response = nil let createRequest = try Fixtures.fixture(name: Fixtures.postInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: nil, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test + func test_create_whenMissingItem_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) - BreezeDynamoDBServiceMock.response = nil let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: nil, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test + func test_read() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 + let keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: Product = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.key == "2023") #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") #expect(response.description == "BreezeLambaAPI is magic 🪄!") - try tearDownWithError() } - - @Test + func test_read_whenInvalidRequest_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 + let keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test func test_read_whenMissingItem_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 + let keyedResponse = Fixtures.product2022 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test func test_update() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 + let keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: Product = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.key == "2023") #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") #expect(response.description == "BreezeLambaAPI is magic 🪄!") - try tearDownWithError() } - @Test func test_update_whenInvalidRequest_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 + let keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test func test_update_whenMissingItem_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 + let keyedResponse = Fixtures.product2022 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test func test_delete() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 + let keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: BreezeEmptyResponse = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response != nil) - try tearDownWithError() } - @Test func test_delete_whenRequestIsOutaded() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.productUdated2023 + let keyedResponse = Fixtures.productUdated2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: BreezeEmptyResponse = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response != nil) - try tearDownWithError() } - @Test func test_delete_whenInvalidRequest_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2023 + let keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test func test_delete_whenMissingItem_thenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) - BreezeDynamoDBServiceMock.keyedResponse = Fixtures.product2022 + let keyedResponse = Fixtures.product2022 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") - try tearDownWithError() } - @Test func test_list() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.list", overwrite: true) - BreezeDynamoDBServiceMock.response = Fixtures.product2023 + let response = Fixtures.product2023 let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) - let response: ListResponse = try apiResponse.decodeBody() - let item = try #require(response.items.first) - #expect(BreezeDynamoDBServiceMock.limit == 1) - #expect(BreezeDynamoDBServiceMock.exclusiveKey == "2023") + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) + let product: ListResponse = try apiResponse.decodeBody() + let item = try #require(product.items.first) +// #expect(BreezeDynamoDBServiceMock.limit == 1) +// #expect(BreezeDynamoDBServiceMock.exclusiveKey == "2023") #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(item.key == "2023") #expect(item.name == "Swift Serverless API with async/await! 🚀🥳") #expect(item.description == "BreezeLambaAPI is magic 🪄!") - try tearDownWithError() } - @Test func test_list_whenError() async throws { - try setUpWithError() setEnvironmentVar(name: "_HANDLER", value: "build/Products.list", overwrite: true) - BreezeDynamoDBServiceMock.response = nil let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: nil, with: request) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidItem") - try tearDownWithError() } } diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index 6fe4a91..d3a700c 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -37,6 +37,9 @@ extension Lambda { static func test( _ handlerType: BreezeLambdaAPIHandler.Type, + config: BreezeDynamoDBConfig, + response: (any BreezeCodable)?, + keyedResponse: (any BreezeCodable)?, with event: BreezeLambdaAPIHandler.Event) async throws -> BreezeLambdaAPIHandler.Output { let logger = Logger(label: "evaluateHandler") @@ -45,14 +48,11 @@ extension Lambda { return try await testGracefulShutdown { gracefulShutdownTestTrigger in let httpClientService = BreezeHTTPClientService(timeout: .seconds(1), logger: logger) - let config = BreezeDynamoDBService.Config( + let serviceConfig = BreezeClientServiceConfig( httpClientService: httpClientService, - region: .useast1, - tableName: "Breeze", - keyName: "key", - endpoint: nil, logger: logger) - let dynamoDBService = BreezeDynamoDBService(with: config) + let dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig, DBManagingType: BreezeDynamoDBServiceMock.self) + let sut = try await handlerType.init(service: dynamoDBService) let serviceGroup = ServiceGroup( @@ -84,7 +84,12 @@ extension Lambda { gracefulShutdownTestTrigger.triggerGracefulShutdown() } let closureHandler = ClosureHandler { event, context in - try await sut.handle(event, context: context) + //Inject Mock Response + let dbManager = await dynamoDBService.dbManager as? BreezeDynamoDBServiceMock + await dbManager?.setupMockResponse(response: response, keyedResponse: keyedResponse) + + // Execute Handler + return try await sut.handle(event, context: context) } var handler = LambdaCodableAdapter( From 3c095789c1927a9a17bac50f2d9642037904da0a Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Tue, 24 Dec 2024 11:22:19 +0100 Subject: [PATCH 05/29] Remove BreezeLambdaAPIHandler --- .../BreezeDynamoDBConfig.swift | 8 +- .../BreezeLambdaAPIHandler.swift | 51 ---- .../BreezeLambdaAPIService.swift | 10 + .../BreezeLambdaAPI/BreezeLambdaHandler.swift | 20 +- .../BreezeLambdaAPI/BreezeLambdaService.swift | 11 +- ....swift => BreezeDynamoDBManagerMock.swift} | 2 +- ...s.swift => BreezeLambdaHandlerTests.swift} | 280 ++++++++++++------ Tests/BreezeLambdaAPITests/Lambda.swift | 29 +- 8 files changed, 240 insertions(+), 171 deletions(-) delete mode 100644 Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift rename Tests/BreezeLambdaAPITests/{BreezeDynamoDBServiceMock.swift => BreezeDynamoDBManagerMock.swift} (97%) rename Tests/BreezeLambdaAPITests/{BreezeLambdaAPITests.swift => BreezeLambdaHandlerTests.swift} (69%) diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift index dad1207..3167542 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift @@ -27,8 +27,8 @@ public struct BreezeDynamoDBConfig: Sendable { self.endpoint = endpoint } - let region: Region - let tableName: String - let keyName: String - let endpoint: String? + public let region: Region + public let tableName: String + public let keyName: String + public let endpoint: String? } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift deleted file mode 100644 index b01b11b..0000000 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIHandler.swift +++ /dev/null @@ -1,51 +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 BreezeDynamoDBService -import SotoDynamoDB - -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -public struct BreezeLambdaAPIHandler: LambdaHandler, Sendable { - let service: BreezeDynamoDBServing - - public init(service: BreezeDynamoDBServing) async throws { - self.service = service - } - - static func operation() throws -> BreezeOperation { - guard let handler = Lambda.env("_HANDLER"), - let operation = BreezeOperation(handler: handler) - else { - throw BreezeLambdaAPIError.invalidHandler - } - return operation - } - - public func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { - let operation = try Self.operation() - context.logger.info("operation: \(operation)") - guard let service = await service.dbManager else { - throw BreezeLambdaAPIError.invalidService - } - return await BreezeLambdaHandler(service: service, operation: operation).handle(context: context, event: event) - } -} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift index 3e50c93..f07fb38 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift @@ -58,6 +58,15 @@ public actor BreezeLambdaAPIService: Service { return nil } + static func operation() throws -> BreezeOperation { + guard let handler = Lambda.env("_HANDLER"), + let operation = BreezeOperation(handler: handler) + else { + throw BreezeLambdaAPIError.invalidHandler + } + return operation + } + public init(dbTimeout: Int64 = 30) throws { do { self.timeout = .seconds(dbTimeout) @@ -78,6 +87,7 @@ public actor BreezeLambdaAPIService: Service { self.dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig) self.breezeLambdaService = BreezeLambdaService( dynamoDBService: dynamoDBService, + operation: try Self.operation(), logger: logger ) self.serviceGroup = ServiceGroup( diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift index 986926b..45ab5d2 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift @@ -17,18 +17,18 @@ import AWSLambdaRuntime import BreezeDynamoDBService import Logging -struct BreezeLambdaHandler { +struct BreezeLambdaHandler: LambdaHandler, Sendable { typealias Event = APIGatewayV2Request typealias Output = APIGatewayV2Response - let service: BreezeDynamoDBManaging + let dbManager: BreezeDynamoDBManaging let operation: BreezeOperation var keyName: String { - self.service.keyName + self.dbManager.keyName } - - func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + + public func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { switch self.operation { case .create: return await self.createLambdaHandler(context: context, event: event) @@ -49,7 +49,7 @@ struct BreezeLambdaHandler { return APIGatewayV2Response(with: error, statusCode: .forbidden) } do { - let result: T = try await service.createItem(item: item) + let result: T = try await dbManager.createItem(item: item) return APIGatewayV2Response(with: result, statusCode: .created) } catch { return APIGatewayV2Response(with: error, statusCode: .forbidden) @@ -62,7 +62,7 @@ struct BreezeLambdaHandler { return APIGatewayV2Response(with: error, statusCode: .forbidden) } do { - let result: T = try await service.readItem(key: key) + let result: T = try await dbManager.readItem(key: key) return APIGatewayV2Response(with: result, statusCode: .ok) } catch { return APIGatewayV2Response(with: error, statusCode: .notFound) @@ -75,7 +75,7 @@ struct BreezeLambdaHandler { return APIGatewayV2Response(with: error, statusCode: .forbidden) } do { - let result: T = try await service.updateItem(item: item) + let result: T = try await dbManager.updateItem(item: item) return APIGatewayV2Response(with: result, statusCode: .ok) } catch { return APIGatewayV2Response(with: error, statusCode: .notFound) @@ -97,7 +97,7 @@ struct BreezeLambdaHandler { } do { let simpleItem = SimpleItem(key: key, createdAt: createdAt, updatedAt: updatedAt) - try await self.service.deleteItem(item: simpleItem) + try await self.dbManager.deleteItem(item: simpleItem) return APIGatewayV2Response(with: BreezeEmptyResponse(), statusCode: .ok) } catch { return APIGatewayV2Response(with: error, statusCode: .notFound) @@ -108,7 +108,7 @@ struct BreezeLambdaHandler { do { let key = event.queryStringParameters?["exclusiveStartKey"] let limit: Int? = event.queryStringParameter("limit") - let result: ListResponse = try await service.listItems(key: key, limit: limit) + let result: ListResponse = try await dbManager.listItems(key: key, limit: limit) return APIGatewayV2Response(with: result, statusCode: .ok) } catch { return APIGatewayV2Response(with: error, statusCode: .forbidden) diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index 95cda02..a4939d6 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -23,14 +23,16 @@ import Logging actor BreezeLambdaService: Service { let dynamoDBService: BreezeDynamoDBServing + let operation: BreezeOperation let logger: Logger - init(dynamoDBService: BreezeDynamoDBServing, logger: Logger) { + init(dynamoDBService: BreezeDynamoDBServing, operation: BreezeOperation, logger: Logger) { self.dynamoDBService = dynamoDBService + self.operation = operation self.logger = logger } - var breezeApi: BreezeLambdaAPIHandler? + var breezeApi: BreezeLambdaHandler? func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { guard let breezeApi else { throw BreezeLambdaAPIError.invalidHandler } @@ -40,7 +42,10 @@ actor BreezeLambdaService: Service { func run() async throws { do { logger.info("Initializing BreezeLambdaAPIHandler...") - let breezeApi = try await BreezeLambdaAPIHandler(service: dynamoDBService) + guard let dbManager = await dynamoDBService.dbManager else { + throw BreezeLambdaAPIError.invalidService + } + let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: operation) self.breezeApi = breezeApi logger.info("Starting BreezeLambdaAPIHandler...") let runtime = LambdaRuntime(body: handler) diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift similarity index 97% rename from Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift rename to Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift index c2f5ce9..0bd7f6c 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift @@ -16,7 +16,7 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB -actor BreezeDynamoDBServiceMock: BreezeDynamoDBManaging { +actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { let keyName: String private var response: (any BreezeCodable)? diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift similarity index 69% rename from Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift rename to Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index f77c5b2..68ec539 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -28,7 +28,7 @@ import Foundation @Suite -struct BreezeLambdaAPITests { +struct BreezeLambdaHandlerTests { let decoder = JSONDecoder() let encoder = JSONEncoder() @@ -37,31 +37,31 @@ struct BreezeLambdaAPITests { let config = BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "sku") - @Test - func testSerially() async throws { - try await test_initWhenMissing__HANDLER_thenThrowError() - try await test_initWhenInvalid__HANDLER_thenThrowError() - - try await test_create() - try await test_create_whenInvalidItem_thenError() - try await test_create_whenMissingItem_thenError() - - try await test_read() - try await test_read_whenInvalidRequest_thenError() - try await test_read_whenMissingItem_thenError() - - try await test_update() - try await test_update_whenInvalidRequest_thenError() - try await test_update_whenMissingItem_thenError() - - try await test_delete() - try await test_delete_whenRequestIsOutaded() - try await test_delete_whenInvalidRequest_thenError() - try await test_delete_whenMissingItem_thenError() - - try await test_list() - try await test_list_whenError() - } +// @Test +// func testSerially() async throws { +// try await test_initWhenMissing__HANDLER_thenThrowError() +// try await test_initWhenInvalid__HANDLER_thenThrowError() +// +// try await test_create() +// try await test_create_whenInvalidItem_thenError() +// try await test_create_whenMissingItem_thenError() +// +// try await test_read() +// try await test_read_whenInvalidRequest_thenError() +// try await test_read_whenMissingItem_thenError() +// +// try await test_update() +// try await test_update_whenInvalidRequest_thenError() +// try await test_update_whenMissingItem_thenError() +// +// try await test_delete() +// try await test_delete_whenRequestIsOutaded() +// try await test_delete_whenInvalidRequest_thenError() +// try await test_delete_whenMissingItem_thenError() +// +// try await test_list() +// try await test_list_whenError() +// } // @Test // func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { @@ -75,34 +75,40 @@ struct BreezeLambdaAPITests { // try tearDownWithError() // } - func test_initWhenMissing__HANDLER_thenThrowError() async throws { - let response = Fixtures.product2023 - let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - do { - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) - Issue.record("It should throw an Error when _HANDLER is missing") - } catch BreezeLambdaAPIError.invalidHandler { - #expect(true) - } catch { - Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") - } - } - - func test_initWhenInvalid__HANDLER_thenThrowError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.c", overwrite: true) - let response = Fixtures.product2023 - let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") - let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - do { - _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) - Issue.record("It should throw an Error when _HANDLER is invalid") - } catch BreezeLambdaAPIError.invalidHandler { - #expect(true) - } catch { - Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") - } - } +// func test_initWhenMissing__HANDLER_thenThrowError() async throws { +// let response = Fixtures.product2023 +// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") +// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) +// do { +// _ = try await Lambda.test( +// BreezeLambdaAPIHandler.self, +// config: config, +// response: response, +// keyedResponse: nil, +// with: request +// ) +// Issue.record("It should throw an Error when _HANDLER is missing") +// } catch BreezeLambdaAPIError.invalidHandler { +// #expect(true) +// } catch { +// Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") +// } +// } +// +// func test_initWhenInvalid__HANDLER_thenThrowError() async throws { +// setEnvironmentVar(name: "_HANDLER", value: "build/Products.c", overwrite: true) +// let response = Fixtures.product2023 +// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") +// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) +// do { +// _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) +// Issue.record("It should throw an Error when _HANDLER is invalid") +// } catch BreezeLambdaAPIError.invalidHandler { +// #expect(true) +// } catch { +// Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") +// } +// } // @Test // func test_initWhenMissing_DYNAMO_DB_TABLE_NAME_thenThrowError() async throws { @@ -142,12 +148,19 @@ struct BreezeLambdaAPITests { // try tearDownWithError() // } + @Test func test_create() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) let response = Fixtures.product2023 let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .create, + response: response, + keyedResponse: nil, + with: request + ) let product: Product = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .created) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) @@ -156,36 +169,55 @@ struct BreezeLambdaAPITests { #expect(product.description == "BreezeLambaAPI is magic 🪄!") } + @Test func test_create_whenInvalidItem_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) let createRequest = try Fixtures.fixture(name: Fixtures.postInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: nil, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .create, + response: nil, + keyedResponse: nil, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } - + @Test func test_create_whenMissingItem_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: nil, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .create, + response: nil, + keyedResponse: nil, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } - + @Test func test_read() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) let keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .read, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: Product = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) @@ -194,36 +226,57 @@ struct BreezeLambdaAPITests { #expect(response.description == "BreezeLambaAPI is magic 🪄!") } + @Test func test_read_whenInvalidRequest_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) let keyedResponse = Fixtures.product2023 let readRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .read, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } + @Test func test_read_whenMissingItem_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.read", overwrite: true) let keyedResponse = Fixtures.product2022 let readRequest = try Fixtures.fixture(name: Fixtures.getProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: readRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .read, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } + @Test func test_update() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) let keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .update, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: Product = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) @@ -232,83 +285,133 @@ struct BreezeLambdaAPITests { #expect(response.description == "BreezeLambaAPI is magic 🪄!") } + @Test func test_update_whenInvalidRequest_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) let keyedResponse = Fixtures.product2023 let updateRequest = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .update, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } + @Test func test_update_whenMissingItem_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.update", overwrite: true) let keyedResponse = Fixtures.product2022 let updateRequest = try Fixtures.fixture(name: Fixtures.putProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: updateRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .update, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } + @Test func test_delete() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) let keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .delete, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: BreezeEmptyResponse = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response != nil) } + @Test func test_delete_whenRequestIsOutaded() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) let keyedResponse = Fixtures.productUdated2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .delete, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: BreezeEmptyResponse = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response != nil) } + @Test func test_delete_whenInvalidRequest_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) let keyedResponse = Fixtures.product2023 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.getInvalidRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .delete, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(response.error == "invalidRequest") } + @Test func test_delete_whenMissingItem_thenError() async throws { setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) let keyedResponse = Fixtures.product2022 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: keyedResponse, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .delete, + response: nil, + keyedResponse: keyedResponse, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) #expect(response.error == "invalidRequest") } + @Test func test_list() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.list", overwrite: true) let response = Fixtures.product2023 let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .list, + response: response, + keyedResponse: nil, + with: request + ) let product: ListResponse = try apiResponse.decodeBody() let item = try #require(product.items.first) // #expect(BreezeDynamoDBServiceMock.limit == 1) @@ -320,11 +423,18 @@ struct BreezeLambdaAPITests { #expect(item.description == "BreezeLambaAPI is magic 🪄!") } + @Test func test_list_whenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.list", overwrite: true) let listRequest = try Fixtures.fixture(name: Fixtures.getProductsRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: listRequest) - let apiResponse: APIGatewayV2Response = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: nil, keyedResponse: nil, with: request) + let apiResponse: APIGatewayV2Response = try await Lambda.test( + BreezeLambdaHandler.self, + config: config, + operation: .list, + response: nil, + keyedResponse: nil, + with: request + ) let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .forbidden) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index d3a700c..c0454f4 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -26,21 +26,23 @@ import ServiceLifecycleTestKit import Foundation import Logging import Testing +import SotoDynamoDB extension Lambda { enum TestState { case none case running - case result(BreezeLambdaAPIHandler.Output) + case result(BreezeLambdaHandler.Output) } static func test( - _ handlerType: BreezeLambdaAPIHandler.Type, + _ handlerType: BreezeLambdaHandler.Type, config: BreezeDynamoDBConfig, + operation: BreezeOperation, response: (any BreezeCodable)?, keyedResponse: (any BreezeCodable)?, - with event: BreezeLambdaAPIHandler.Event) async throws -> BreezeLambdaAPIHandler.Output { + with event: BreezeLambdaHandler.Event) async throws -> BreezeLambdaHandler.Output { let logger = Logger(label: "evaluateHandler") let decoder = JSONDecoder() @@ -48,12 +50,10 @@ extension Lambda { return try await testGracefulShutdown { gracefulShutdownTestTrigger in let httpClientService = BreezeHTTPClientService(timeout: .seconds(1), logger: logger) - let serviceConfig = BreezeClientServiceConfig( - httpClientService: httpClientService, - logger: logger) - let dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig, DBManagingType: BreezeDynamoDBServiceMock.self) - - let sut = try await handlerType.init(service: dynamoDBService) + let awsClient = AWSClient() + let db = SotoDynamoDB.DynamoDB(client: awsClient) + let dbManager = BreezeDynamoDBManagerMock(db: db, tableName: config.tableName, keyName: config.keyName) + let sut = handlerType.init(dbManager: dbManager, operation: operation) let serviceGroup = ServiceGroup( configuration: .init( @@ -62,11 +62,6 @@ extension Lambda { service: httpClientService, successTerminationBehavior: .ignore, failureTerminationBehavior: .gracefullyShutdownGroup - ), - .init( - service: dynamoDBService, - successTerminationBehavior: .gracefullyShutdownGroup, - failureTerminationBehavior: .gracefullyShutdownGroup ) ], logger: logger @@ -76,6 +71,7 @@ extension Lambda { let testState = try await withThrowingTaskGroup(of: TestState.self) { group in group.addTask { try await serviceGroup.run() + try await awsClient.shutdown() return TestState.running } @@ -85,8 +81,7 @@ extension Lambda { } let closureHandler = ClosureHandler { event, context in //Inject Mock Response - let dbManager = await dynamoDBService.dbManager as? BreezeDynamoDBServiceMock - await dbManager?.setupMockResponse(response: response, keyedResponse: keyedResponse) + await dbManager.setupMockResponse(response: response, keyedResponse: keyedResponse) // Execute Handler return try await sut.handle(event, context: context) @@ -111,7 +106,7 @@ extension Lambda { try await handler.handle(event, responseWriter: writer, context: context) let result = await writer.output ?? ByteBuffer() - return TestState.result(try decoder.decode(BreezeLambdaAPIHandler.Output.self, from: result)) + return TestState.result(try decoder.decode(BreezeLambdaHandler.Output.self, from: result)) } for try await value in group { switch value { From b3cec46a90dcc3bfb4dec86cfe45ff96cbce73e6 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Tue, 24 Dec 2024 11:40:54 +0100 Subject: [PATCH 06/29] Simplify Unit Tests --- .../BreezeLambdaHandlerTests.swift | 27 ----- Tests/BreezeLambdaAPITests/Lambda.swift | 114 +++++------------- 2 files changed, 31 insertions(+), 110 deletions(-) diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index 68ec539..28f7938 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -34,34 +34,7 @@ struct BreezeLambdaHandlerTests { let encoder = JSONEncoder() let logger = Logger(label: "BreezeLambdaAPITests") - let config = BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "sku") - -// @Test -// func testSerially() async throws { -// try await test_initWhenMissing__HANDLER_thenThrowError() -// try await test_initWhenInvalid__HANDLER_thenThrowError() -// -// try await test_create() -// try await test_create_whenInvalidItem_thenError() -// try await test_create_whenMissingItem_thenError() -// -// try await test_read() -// try await test_read_whenInvalidRequest_thenError() -// try await test_read_whenMissingItem_thenError() -// -// try await test_update() -// try await test_update_whenInvalidRequest_thenError() -// try await test_update_whenMissingItem_thenError() -// -// try await test_delete() -// try await test_delete_whenRequestIsOutaded() -// try await test_delete_whenInvalidRequest_thenError() -// try await test_delete_whenMissingItem_thenError() -// -// try await test_list() -// try await test_list_whenError() -// } // @Test // func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index c0454f4..9cf4578 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -27,15 +27,10 @@ import Foundation import Logging import Testing import SotoDynamoDB +import AsyncHTTPClient extension Lambda { - enum TestState { - case none - case running - case result(BreezeLambdaHandler.Output) - } - static func test( _ handlerType: BreezeLambdaHandler.Type, config: BreezeDynamoDBConfig, @@ -48,83 +43,36 @@ extension Lambda { let decoder = JSONDecoder() let encoder = JSONEncoder() - return try await testGracefulShutdown { gracefulShutdownTestTrigger in - let httpClientService = BreezeHTTPClientService(timeout: .seconds(1), logger: logger) - let awsClient = AWSClient() - let db = SotoDynamoDB.DynamoDB(client: awsClient) - let dbManager = BreezeDynamoDBManagerMock(db: db, tableName: config.tableName, keyName: config.keyName) - let sut = handlerType.init(dbManager: dbManager, operation: operation) - - let serviceGroup = ServiceGroup( - configuration: .init( - services: [ - .init( - service: httpClientService, - successTerminationBehavior: .ignore, - failureTerminationBehavior: .gracefullyShutdownGroup - ) - ], - logger: logger - ) - ) - - let testState = try await withThrowingTaskGroup(of: TestState.self) { group in - group.addTask { - try await serviceGroup.run() - try await awsClient.shutdown() - return TestState.running - } - - group.addTask { - defer { - gracefulShutdownTestTrigger.triggerGracefulShutdown() - } - let closureHandler = ClosureHandler { event, context in - //Inject Mock Response - await dbManager.setupMockResponse(response: response, keyedResponse: keyedResponse) - - // Execute Handler - return 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() - return TestState.result(try decoder.decode(BreezeLambdaHandler.Output.self, from: result)) - } - for try await value in group { - switch value { - case .none, .running: - break - case .result: - return value - } - } - return TestState.none - } - - switch testState { - case .none, .running: - return APIGatewayV2Response(with: "", statusCode: .noContent) - case .result(let response): - return response - } + let awsClient = AWSClient() + let db = SotoDynamoDB.DynamoDB(client: awsClient) + let dbManager = BreezeDynamoDBManagerMock(db: db, tableName: config.tableName, keyName: config.keyName) + let sut = handlerType.init(dbManager: dbManager, operation: operation) + + let closureHandler = ClosureHandler { event, context in + //Inject Mock Response + await dbManager.setupMockResponse(response: response, keyedResponse: keyedResponse) + // Execute Handler + return 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 awsClient.shutdown() + return try decoder.decode(BreezeLambdaHandler.Output.self, from: result) } } From 61a2932078324133ba797fa6c5623fb918b5f8c3 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Tue, 24 Dec 2024 15:57:50 +0100 Subject: [PATCH 07/29] Update test with configuration injection --- .../BreezeDemoApplication.swift | 42 +++++++-- .../BreezeDynamoDBService/BreezeCodable.swift | 2 +- .../BreezeDynamoDBConfig.swift | 2 +- .../BreezeDynamoDBManager.swift | 2 +- .../BreezeDynamoDBManaging.swift | 2 +- .../BreezeDynamoDBService.swift | 47 +++++----- .../Foundation+Extension.swift | 2 +- .../BreezeDynamoDBService/ListResponse.swift | 2 +- .../BreezeClientServiceConfig.swift | 2 +- .../BreezeHTTPClientService.swift | 4 +- .../APIGatewayV2Request+Extensions.swift | 2 +- .../APIGatewayV2Response+Extensions.swift | 2 +- .../BreezeAPIConfiguration.swift | 72 +++++++++++++++ .../BreezeLambdaAPI/BreezeEmptyResponse.swift | 2 +- ...APIService.swift => BreezeLambdaAPI.swift} | 65 +++----------- .../BreezeLambdaAPIError.swift | 2 +- .../BreezeLambdaAPI/BreezeLambdaHandler.swift | 4 +- .../BreezeLambdaAPI/BreezeLambdaService.swift | 14 ++- Sources/BreezeLambdaAPI/BreezeOperation.swift | 4 +- .../BreezeDynamoDBManagerTests.swift | 2 +- .../LocalStackDynamoDB.swift | 2 +- Tests/BreezeDynamoDBServiceTests/Utils.swift | 2 +- .../BreezeHTTPClientServiceTests.swift | 2 +- .../APIGatewayV2Response.swift | 2 +- .../BreezeDynamoDBManagerMock.swift | 2 +- .../BreezeLambdaAPIServiceTests.swift | 81 +++++++++++++++++ .../BreezeLambdaHandlerTests.swift | 87 +------------------ .../BreezeOperationTests.swift | 2 +- Tests/BreezeLambdaAPITests/Fixtures.swift | 2 +- Tests/BreezeLambdaAPITests/Lambda.swift | 2 +- Tests/BreezeLambdaAPITests/Product.swift | 2 +- Tests/BreezeLambdaAPITests/Utils.swift | 2 +- 32 files changed, 260 insertions(+), 204 deletions(-) create mode 100644 Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift rename Sources/BreezeLambdaAPI/{BreezeLambdaAPIService.swift => BreezeLambdaAPI.swift} (60%) create mode 100644 Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift diff --git a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift index 37b0d12..fa520cd 100644 --- a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift +++ b/Sources/BreezeDemoApplication/BreezeDemoApplication.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. @@ -15,17 +15,43 @@ import BreezeLambdaAPI import BreezeDynamoDBService -struct Message: BreezeCodable { - var key: String - let message: String - var createdAt: String? - var updatedAt: String? +struct Item: Codable { + public var key: String + public let name: String + public let description: String + public var createdAt: String? + public var updatedAt: String? + + enum CodingKeys: String, CodingKey { + case key = "itemKey" + case name + case description + case createdAt + case updatedAt + } +} + +extension Item: BreezeCodable { } + +struct APIConfiguration: APIConfiguring { + let dbTimeout: Int64 = 30 + func operation() throws -> BreezeOperation { + .list + } + + func getConfig() throws -> BreezeDynamoDBConfig { + BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "itemKey", endpoint: "http://127.0.0.1:4566") + } } @main struct BreezeDemoApplication { static func main() async throws { - let lambdaAPIService = try BreezeLambdaAPIService(dbTimeout: 30) - try await lambdaAPIService.run() + do { + let lambdaAPIService = try BreezeLambdaAPI(apiConfig: APIConfiguration()) + try await lambdaAPIService.run() + } catch { + print(error.localizedDescription) + } } } diff --git a/Sources/BreezeDynamoDBService/BreezeCodable.swift b/Sources/BreezeDynamoDBService/BreezeCodable.swift index 4c49ddc..93077f1 100644 --- a/Sources/BreezeDynamoDBService/BreezeCodable.swift +++ b/Sources/BreezeDynamoDBService/BreezeCodable.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. diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift index 3167542..ea4b05c 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.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. diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift index f21923c..fd15eda 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.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. diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift index 3f5caf9..b4208f8 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.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. diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index 1d73b80..bfcb695 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.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. @@ -18,16 +18,37 @@ import BreezeHTTPClientService import Logging public protocol BreezeDynamoDBServing: Actor, Service { - var dbManager: BreezeDynamoDBManaging? { get } + func dbManager() async -> BreezeDynamoDBManaging } public actor BreezeDynamoDBService: BreezeDynamoDBServing { - - public var dbManager: BreezeDynamoDBManaging? + + private var _dbManager: BreezeDynamoDBManaging? private let config: BreezeDynamoDBConfig private let serviceConfig: BreezeClientServiceConfig private let DBManagingType: BreezeDynamoDBManaging.Type + public func dbManager() async -> BreezeDynamoDBManaging { + if let _dbManager { + return _dbManager + } + let httpClient = await serviceConfig.httpClientService.httpClient + let awsClient = AWSClient(httpClient: httpClient) + self.awsClient = awsClient + let db = SotoDynamoDB.DynamoDB( + client: awsClient, + region: config.region, + endpoint: config.endpoint + ) + let dbManager = DBManagingType.init( + db: db, + tableName: config.tableName, + keyName: config.keyName + ) + _dbManager = dbManager + return dbManager + } + public init( config: BreezeDynamoDBConfig, serviceConfig: BreezeClientServiceConfig, @@ -46,20 +67,6 @@ public actor BreezeDynamoDBService: BreezeDynamoDBServing { public func run() async throws { logger.info("Starting DynamoDBService...") - let httpClient = await serviceConfig.httpClientService.httpClient - let awsClient = AWSClient(httpClient: httpClient) - self.awsClient = awsClient - let db = SotoDynamoDB.DynamoDB( - client: awsClient, - region: config.region, - endpoint: config.endpoint - ) - self.dbManager = DBManagingType.init( - db: db, - tableName: config.tableName, - keyName: config.keyName - ) - logger.info("DynamoDBService is running with config...") logger.info("region: \(config.region)") logger.info("tableName: \(config.tableName)") @@ -67,8 +74,8 @@ public actor BreezeDynamoDBService: BreezeDynamoDBServing { try await gracefulShutdown() - logger.info("Shutting down DynamoDBService...") - try await awsClient.shutdown() + logger.info("Stopping DynamoDBService...") + try await awsClient?.shutdown() self.awsClient = nil logger.info("DynamoDBService is stopped.") } diff --git a/Sources/BreezeDynamoDBService/Foundation+Extension.swift b/Sources/BreezeDynamoDBService/Foundation+Extension.swift index 817bc29..63ac42c 100644 --- a/Sources/BreezeDynamoDBService/Foundation+Extension.swift +++ b/Sources/BreezeDynamoDBService/Foundation+Extension.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. diff --git a/Sources/BreezeDynamoDBService/ListResponse.swift b/Sources/BreezeDynamoDBService/ListResponse.swift index 307b7f0..0932fe2 100644 --- a/Sources/BreezeDynamoDBService/ListResponse.swift +++ b/Sources/BreezeDynamoDBService/ListResponse.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. diff --git a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift index d7b11ed..25579d4 100644 --- a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift +++ b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.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. diff --git a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift index fff5d77..1a31b26 100644 --- a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift +++ b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.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. @@ -45,7 +45,7 @@ public actor BreezeHTTPClientService: BreezeHTTPClientServing { logger.info("HTTPClientService started...") try await gracefulShutdown() - logger.info("Shutting down HTTPClientService...") + logger.info("Stopping HTTPClientService...") try await httpClient.shutdown() logger.info("HTTPClientService shutdown completed.") } diff --git a/Sources/BreezeLambdaAPI/APIGatewayV2Request+Extensions.swift b/Sources/BreezeLambdaAPI/APIGatewayV2Request+Extensions.swift index 392ed55..478972e 100644 --- a/Sources/BreezeLambdaAPI/APIGatewayV2Request+Extensions.swift +++ b/Sources/BreezeLambdaAPI/APIGatewayV2Request+Extensions.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. diff --git a/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift index 1be3274..a334f9a 100644 --- a/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift +++ b/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.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. diff --git a/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift b/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift new file mode 100644 index 0000000..127d6b6 --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift @@ -0,0 +1,72 @@ +// +// BreezeAPIConfiguration.swift +// BreezeLambdaDynamoDBAPI +// +// Created by Andrea Scuderi on 24/12/2024. +// + +import SotoDynamoDB +import BreezeDynamoDBService +import AWSLambdaRuntime + +public protocol APIConfiguring { + var dbTimeout: Int64 { get } + func operation() throws -> BreezeOperation + func getConfig() throws -> BreezeDynamoDBConfig +} + +public struct BreezeAPIConfiguration: APIConfiguring { + + public init() {} + + public let dbTimeout: Int64 = 30 + + public func operation() throws -> BreezeOperation { + guard let handler = Lambda.env("_HANDLER"), + let operation = BreezeOperation(handler: handler) + else { + throw BreezeLambdaAPIError.invalidHandler + } + return operation + } + + public func getConfig() throws -> BreezeDynamoDBConfig { + BreezeDynamoDBConfig( + region: currentRegion(), + tableName: try tableName(), + keyName: try keyName(), + endpoint: endpoint() + ) + } + + func currentRegion() -> Region { + if let awsRegion = Lambda.env("AWS_REGION") { + let value = Region(rawValue: awsRegion) + return value + } else { + return .useast1 + } + } + + func tableName() throws -> String { + guard let tableName = Lambda.env("DYNAMO_DB_TABLE_NAME") else { + throw BreezeLambdaAPIError.tableNameNotFound + } + return tableName + } + + func keyName() throws -> String { + guard let tableName = Lambda.env("DYNAMO_DB_KEY") else { + throw BreezeLambdaAPIError.keyNameNotFound + } + return tableName + } + + func endpoint() -> String? { + if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), + !localstack.isEmpty { + return localstack + } + return nil + } +} diff --git a/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift b/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift index c383bb2..1a00b15 100644 --- a/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift +++ b/Sources/BreezeLambdaAPI/BreezeEmptyResponse.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. diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift similarity index 60% rename from Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift rename to Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index f07fb38..7364ef9 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.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. @@ -18,68 +18,25 @@ import BreezeDynamoDBService import BreezeHTTPClientService import AWSLambdaRuntime -public actor BreezeLambdaAPIService: Service { +public actor BreezeLambdaAPI: Service { - let logger = Logger(label: "service-group") + let logger = Logger(label: "service-group-breeze-lambda-api") let timeout: TimeAmount let httpClientService: BreezeHTTPClientServing let dynamoDBService: BreezeDynamoDBServing let breezeLambdaService: BreezeLambdaService private let serviceGroup: ServiceGroup + private let apiConfig: any APIConfiguring - static func currentRegion() -> Region { - if let awsRegion = Lambda.env("AWS_REGION") { - let value = Region(rawValue: awsRegion) - return value - } else { - return .useast1 - } - } - - static func tableName() throws -> String { - guard let tableName = Lambda.env("DYNAMO_DB_TABLE_NAME") else { - throw BreezeLambdaAPIError.tableNameNotFound - } - return tableName - } - - static func keyName() throws -> String { - guard let tableName = Lambda.env("DYNAMO_DB_KEY") else { - throw BreezeLambdaAPIError.keyNameNotFound - } - return tableName - } - - static func endpoint() -> String? { - if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), - !localstack.isEmpty { - return localstack - } - return nil - } - - static func operation() throws -> BreezeOperation { - guard let handler = Lambda.env("_HANDLER"), - let operation = BreezeOperation(handler: handler) - else { - throw BreezeLambdaAPIError.invalidHandler - } - return operation - } - - public init(dbTimeout: Int64 = 30) throws { + public init(apiConfig: APIConfiguring = BreezeAPIConfiguration()) throws { do { - self.timeout = .seconds(dbTimeout) + self.apiConfig = apiConfig + self.timeout = .seconds(apiConfig.dbTimeout) self.httpClientService = BreezeHTTPClientService( timeout: timeout, logger: logger ) - let config = BreezeDynamoDBConfig( - region: Self.currentRegion(), - tableName: try Self.tableName(), - keyName: try Self.keyName(), - endpoint: Self.endpoint() - ) + let config = try apiConfig.getConfig() let serviceConfig = BreezeClientServiceConfig( httpClientService: httpClientService, logger: logger @@ -87,7 +44,7 @@ public actor BreezeLambdaAPIService: Service { self.dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig) self.breezeLambdaService = BreezeLambdaService( dynamoDBService: dynamoDBService, - operation: try Self.operation(), + operation: try apiConfig.operation(), logger: logger ) self.serviceGroup = ServiceGroup( @@ -114,14 +71,14 @@ public actor BreezeLambdaAPIService: Service { ) } catch { logger.error("\(error.localizedDescription)") - fatalError(error.localizedDescription) + throw error } } public func run() async throws { logger.info("Starting BreezeLambdaAPIService...") try await serviceGroup.run() - logger.info("Shutting down BreezeLambdaAPIService...") + logger.info("Stopping BreezeLambdaAPIService...") try await gracefulShutdown() logger.info("BreezeLambdaAPIService is stopped.") } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift index a66a626..ead9910 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.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. diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift index 45ab5d2..557644b 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.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. @@ -28,7 +28,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { self.dbManager.keyName } - public func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { switch self.operation { case .create: return await self.createLambdaHandler(context: context, event: event) diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index a4939d6..f3f71a6 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.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. @@ -41,19 +41,17 @@ actor BreezeLambdaService: Service { func run() async throws { do { - logger.info("Initializing BreezeLambdaAPIHandler...") - guard let dbManager = await dynamoDBService.dbManager else { - throw BreezeLambdaAPIError.invalidService - } + logger.info("Starting BreezeLambdaService...") + let dbManager = await dynamoDBService.dbManager() let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: operation) self.breezeApi = breezeApi - logger.info("Starting BreezeLambdaAPIHandler...") + logger.info("Starting BreezeLambdaService...") let runtime = LambdaRuntime(body: handler) try await runtime.run() - logger.info("BreezeLambdaAPIHandler stopped.") + logger.info("BreezeLambdaService stopped.") } catch { logger.error("\(error.localizedDescription)") - fatalError("\(error.localizedDescription)") + throw error } } } diff --git a/Sources/BreezeLambdaAPI/BreezeOperation.swift b/Sources/BreezeLambdaAPI/BreezeOperation.swift index 4e50c8e..8169cfd 100644 --- a/Sources/BreezeLambdaAPI/BreezeOperation.swift +++ b/Sources/BreezeLambdaAPI/BreezeOperation.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,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -enum BreezeOperation: String { +public enum BreezeOperation: String, Sendable { case create case read case update diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift index c681bfe..0d2d775 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.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. diff --git a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift index 4c84e5c..b24a284 100644 --- a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift +++ b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.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. diff --git a/Tests/BreezeDynamoDBServiceTests/Utils.swift b/Tests/BreezeDynamoDBServiceTests/Utils.swift index b36119c..675b3c4 100644 --- a/Tests/BreezeDynamoDBServiceTests/Utils.swift +++ b/Tests/BreezeDynamoDBServiceTests/Utils.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. diff --git a/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift index 338afd7..c6621cd 100644 --- a/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift +++ b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.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. diff --git a/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift b/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift index a9418ee..76e33c4 100644 --- a/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift +++ b/Tests/BreezeLambdaAPITests/APIGatewayV2Response.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. diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift index 0bd7f6c..33297d5 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.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. diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift new file mode 100644 index 0000000..1917d4c --- /dev/null +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift @@ -0,0 +1,81 @@ +// 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. + +@testable import BreezeLambdaAPI +import Logging +import Testing +import ServiceLifecycle +import ServiceLifecycleTestKit +import BreezeDynamoDBService + +struct APIConfiguration: APIConfiguring { + var dbTimeout: Int64 = 30 + + func operation() throws -> BreezeOperation { + .list + } + func getConfig() throws -> BreezeDynamoDBConfig { + BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "itemKey", endpoint: "http://127.0.0.1:4566") + } +} + +@Suite +struct BreezeLambdaAPIServiceTests { + + let logger = Logger(label: "BreezeHTTPClientServiceTests") + + @Test + func test_breezeLambdaAPIService_whenValidEnvironment() async throws { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + try await withThrowingTaskGroup(of: Void.self) { group in + let sut = try BreezeLambdaAPI(apiConfig: APIConfiguration()) + 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() + } + group.cancelAll() + } + } + } + + @Test + func test_breezeLambdaAPIService_whenInvalidEnvironment() async throws { + await #expect(throws: BreezeLambdaAPIError.self) { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + try await withThrowingTaskGroup(of: Void.self) { group in + let sut = try BreezeLambdaAPI() + group.addTask { + try await withGracefulShutdownHandler { + try await sut.run() + } onGracefulShutdown: { + logger.info("Performing onGracefulShutdown") + } + } + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + group.cancelAll() + } + } + } + } +} diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index 28f7938..4659f5a 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.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. @@ -35,91 +35,6 @@ struct BreezeLambdaHandlerTests { let logger = Logger(label: "BreezeLambdaAPITests") let config = BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "sku") - -// @Test -// func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { -// try setUpWithError() -//// unsetenv("AWS_REGION") -// setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) -// let response = Fixtures.product2023 -// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") -// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) -// try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) -// try tearDownWithError() -// } - -// func test_initWhenMissing__HANDLER_thenThrowError() async throws { -// let response = Fixtures.product2023 -// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") -// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) -// do { -// _ = try await Lambda.test( -// BreezeLambdaAPIHandler.self, -// config: config, -// response: response, -// keyedResponse: nil, -// with: request -// ) -// Issue.record("It should throw an Error when _HANDLER is missing") -// } catch BreezeLambdaAPIError.invalidHandler { -// #expect(true) -// } catch { -// Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") -// } -// } -// -// func test_initWhenInvalid__HANDLER_thenThrowError() async throws { -// setEnvironmentVar(name: "_HANDLER", value: "build/Products.c", overwrite: true) -// let response = Fixtures.product2023 -// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") -// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) -// do { -// _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, response: response, keyedResponse: nil, with: request) -// Issue.record("It should throw an Error when _HANDLER is invalid") -// } catch BreezeLambdaAPIError.invalidHandler { -// #expect(true) -// } catch { -// Issue.record("Is should throw an BreezeLambdaAPIError.invalidHandler") -// } -// } - -// @Test -// func test_initWhenMissing_DYNAMO_DB_TABLE_NAME_thenThrowError() async throws { -// try setUpWithError() -// unsetenv("DYNAMO_DB_TABLE_NAME") -// setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) -// BreezeDynamoDBServiceMock.response = Fixtures.product2023 -// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") -// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) -// do { -// let value = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, with: request) -// Issue.record("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") -// } catch BreezeLambdaAPIError.tableNameNotFound { -// #expect(true) -// } catch { -// Issue.record("Is should throw an BreezeLambdaAPIError.tableNameNotFound") -// } -// try tearDownWithError() -// } - -// @Test -// func test_initWhenMissing_DYNAMO_DB_KEY_thenThrowError() async throws { -// try setUpWithError() -// unsetenv("DYNAMO_DB_KEY") -// setEnvironmentVar(name: "_HANDLER", value: "build/Products.create", overwrite: true) -// BreezeDynamoDBServiceMock.response = Fixtures.product2023 -// let createRequest = try Fixtures.fixture(name: Fixtures.postProductsRequest, type: "json") -// let request = try decoder.decode(APIGatewayV2Request.self, from: createRequest) -// do { -// _ = try await Lambda.test(BreezeLambdaAPIHandler.self, config: config, with: request) -// Issue.record("It should throw an Error when DYNAMO_DB_KEY is missing") -// } catch BreezeLambdaAPIError.keyNameNotFound { -// #expect(true) -// } catch { -// Issue.record("Is should throw an BreezeLambdaAPIError.keyNameNotFound") -// } -// try tearDownWithError() -// } @Test func test_create() async throws { diff --git a/Tests/BreezeLambdaAPITests/BreezeOperationTests.swift b/Tests/BreezeLambdaAPITests/BreezeOperationTests.swift index 6ca2e59..9782530 100644 --- a/Tests/BreezeLambdaAPITests/BreezeOperationTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeOperationTests.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. diff --git a/Tests/BreezeLambdaAPITests/Fixtures.swift b/Tests/BreezeLambdaAPITests/Fixtures.swift index ea7acf0..26f61a4 100644 --- a/Tests/BreezeLambdaAPITests/Fixtures.swift +++ b/Tests/BreezeLambdaAPITests/Fixtures.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. diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index 9cf4578..0006c39 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/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. diff --git a/Tests/BreezeLambdaAPITests/Product.swift b/Tests/BreezeLambdaAPITests/Product.swift index 51fa965..9da364b 100644 --- a/Tests/BreezeLambdaAPITests/Product.swift +++ b/Tests/BreezeLambdaAPITests/Product.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. diff --git a/Tests/BreezeLambdaAPITests/Utils.swift b/Tests/BreezeLambdaAPITests/Utils.swift index 5e97928..cea2571 100644 --- a/Tests/BreezeLambdaAPITests/Utils.swift +++ b/Tests/BreezeLambdaAPITests/Utils.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. From d45e85effef42d29cc2091d4c726c9d82735adbc Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Tue, 24 Dec 2024 16:18:33 +0100 Subject: [PATCH 08/29] Fix Unit Tests on Linux --- Package.swift | 1 + .../LocalStackDynamoDB.swift | 5 ++-- Tests/BreezeDynamoDBServiceTests/Utils.swift | 30 ------------------- .../BreezeLambdaHandlerTests.swift | 1 - Tests/BreezeLambdaAPITests/Fixtures.swift | 4 --- Tests/BreezeLambdaAPITests/Utils.swift | 23 -------------- 6 files changed, 4 insertions(+), 60 deletions(-) delete mode 100644 Tests/BreezeDynamoDBServiceTests/Utils.swift delete mode 100644 Tests/BreezeLambdaAPITests/Utils.swift diff --git a/Package.swift b/Package.swift index f1e1ffd..bfd37db 100644 --- a/Package.swift +++ b/Package.swift @@ -82,6 +82,7 @@ let package = Package( .testTarget( name: "BreezeDynamoDBServiceTests", dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "BreezeDynamoDBService" diff --git a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift index b24a284..376898b 100644 --- a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift +++ b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift @@ -13,12 +13,13 @@ // limitations under the License. import SotoDynamoDB +import AWSLambdaRuntime import Logging enum LocalStackDynamoDB { static let endpoint: String = { - if let localstack = getEnvironmentVar(name: "LOCALSTACK_ENDPOINT"), + if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), !localstack.isEmpty { return localstack } @@ -26,7 +27,7 @@ enum LocalStackDynamoDB { }() public static let logger: Logger = { - if let loggingLevel = getEnvironmentVar(name: "AWS_LOG_LEVEL") { + if let loggingLevel = Lambda.env("AWS_LOG_LEVEL") { if let logLevel = Logger.Level(rawValue: loggingLevel.lowercased()) { var logger = Logger(label: "breeze") logger.logLevel = logLevel diff --git a/Tests/BreezeDynamoDBServiceTests/Utils.swift b/Tests/BreezeDynamoDBServiceTests/Utils.swift deleted file mode 100644 index 675b3c4..0000000 --- a/Tests/BreezeDynamoDBServiceTests/Utils.swift +++ /dev/null @@ -1,30 +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. - -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -func getEnvironmentVar(name: String) -> String? { - guard let envValue = getenv(name) else { - return nil - } - return String(cString: envValue) -} - -func setEnvironmentVar(name: String, value: String, overwrite: Bool) { - setenv(name, value, overwrite ? 1 : 0) -} diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index 4659f5a..d858858 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -269,7 +269,6 @@ struct BreezeLambdaHandlerTests { @Test func test_delete_whenMissingItem_thenError() async throws { - setEnvironmentVar(name: "_HANDLER", value: "build/Products.delete", overwrite: true) let keyedResponse = Fixtures.product2022 let deleteProductsSku = try Fixtures.fixture(name: Fixtures.deleteProductsSkuRequest, type: "json") let request = try decoder.decode(APIGatewayV2Request.self, from: deleteProductsSku) diff --git a/Tests/BreezeLambdaAPITests/Fixtures.swift b/Tests/BreezeLambdaAPITests/Fixtures.swift index 26f61a4..0719753 100644 --- a/Tests/BreezeLambdaAPITests/Fixtures.swift +++ b/Tests/BreezeLambdaAPITests/Fixtures.swift @@ -12,11 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import BreezeLambdaAPI import AWSLambdaEvents diff --git a/Tests/BreezeLambdaAPITests/Utils.swift b/Tests/BreezeLambdaAPITests/Utils.swift deleted file mode 100644 index cea2571..0000000 --- a/Tests/BreezeLambdaAPITests/Utils.swift +++ /dev/null @@ -1,23 +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. - -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -func setEnvironmentVar(name: String, value: String, overwrite: Bool) { - setenv(name, value, overwrite ? 1 : 0) -} - From 4565e3c7be9bf14c1fe47c1c804e085dbc3f91af Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Tue, 24 Dec 2024 16:18:57 +0100 Subject: [PATCH 09/29] Set CI wil Swift 6.0.3 --- .github/workflows/meterian.yml | 2 +- .github/workflows/swift-test.yml | 2 +- docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml index f352bc2..ec2dafc 100644 --- a/.github/workflows/meterian.yml +++ b/.github/workflows/meterian.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: swift-actions/setup-swift@v2 with: - swift-version: "5.7.3" + swift-version: "6.0.3" - name: Get swift version run: swift --version - name: Checkout diff --git a/.github/workflows/swift-test.yml b/.github/workflows/swift-test.yml index 06a1fb4..c9ce171 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.0.3 services: localstack: image: localstack/localstack diff --git a/docker/Dockerfile b/docker/Dockerfile index 6c4e734..b60a24a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.0.1-amazonlinux2 as builder +FROM swift:6.0.3-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make From f8b38e193cf12decd421c67bd5daa1af288d3420 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Mon, 5 May 2025 14:51:36 +0100 Subject: [PATCH 10/29] Update aws-lambda-runtime dependency to latest main version --- Package.resolved | 20 +++++++++---------- Package.swift | 2 +- .../BreezeLambdaAPI/BreezeLambdaHandler.swift | 10 +++++----- .../BreezeLambdaHandlerTests.swift | 3 +-- Tests/BreezeLambdaAPITests/Lambda.swift | 6 ++---- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/Package.resolved b/Package.resolved index 782d3fe..c9e4002 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "03296d2dcaccdb6b440525d8308a9dbf7ead03b7bc38c1fa9fbe70d640f7d469", + "originHash" : "672f8141a46f0621c29e23ab11f1efa73c11bdecea3447837c9f1766e045082a", "pins" : [ { "identity" : "async-http-client", @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", - "version" : "1.0.3" + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" } }, { @@ -88,7 +88,7 @@ "location" : "https://github.com/swift-server/swift-aws-lambda-runtime.git", "state" : { "branch" : "main", - "revision" : "18660fcdd1cdaee43550dfa47df554b7e297702a" + "revision" : "5924fb6e75b76d45bf427e02c0017d733b235903" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "dca6594f65308c761a9c409e09fbf35f48d50d34", - "version" : "2.77.0" + "revision" : "0f54d58bb5db9e064f332e8524150de379d1e51c", + "version" : "2.82.1" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "f70b838872863396a25694d8b19fe58bcd0b7903", - "version" : "2.6.2" + "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", + "version" : "2.8.0" } }, { diff --git a/Package.swift b/Package.swift index bfd37db..eec0984 100644 --- a/Package.swift +++ b/Package.swift @@ -72,7 +72,7 @@ let package = Package( .testTarget( name: "BreezeLambdaAPITests", dependencies: [ - .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "BreezeLambdaAPI" diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift index 557644b..41b847e 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift @@ -43,7 +43,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } - func createLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + func createLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let item: T = try? event.bodyObject() else { let error = BreezeLambdaAPIError.invalidRequest return APIGatewayV2Response(with: error, statusCode: .forbidden) @@ -56,7 +56,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } - func readLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + func readLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let key = event.pathParameters?[keyName] else { let error = BreezeLambdaAPIError.invalidRequest return APIGatewayV2Response(with: error, statusCode: .forbidden) @@ -69,7 +69,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } - func updateLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + func updateLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let item: T = try? event.bodyObject() else { let error = BreezeLambdaAPIError.invalidRequest return APIGatewayV2Response(with: error, statusCode: .forbidden) @@ -88,7 +88,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { var updatedAt: String? } - func deleteLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + func deleteLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let key = event.pathParameters?[keyName], let createdAt = event.queryStringParameters?["createdAt"], let updatedAt = event.queryStringParameters?["updatedAt"] else { @@ -104,7 +104,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } - func listLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + func listLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { do { let key = event.queryStringParameters?["exclusiveStartKey"] let limit: Int? = event.queryStringParameter("limit") diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index d858858..e1eda57 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -13,13 +13,12 @@ // limitations under the License. import AWSLambdaEvents -import AWSLambdaRuntime +@testable import AWSLambdaRuntime import ServiceLifecycle import ServiceLifecycleTestKit import BreezeDynamoDBService import BreezeHTTPClientService @testable import BreezeLambdaAPI -@testable import AWSLambdaRuntimeCore import Testing import Logging import AsyncHTTPClient diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index 0006c39..a6da1cf 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -13,12 +13,10 @@ // limitations under the License. import AWSLambdaEvents -import AWSLambdaRuntime +@testable import AWSLambdaRuntime import BreezeDynamoDBService import BreezeHTTPClientService @testable import BreezeLambdaAPI -@testable import AWSLambdaRuntimeCore -import AWSLambdaTesting import Logging import NIO import ServiceLifecycle @@ -29,7 +27,7 @@ import Testing import SotoDynamoDB import AsyncHTTPClient -extension Lambda { +extension AWSLambdaRuntime.Lambda { static func test( _ handlerType: BreezeLambdaHandler.Type, From a4d3fb9a91e4312202816451cea12eae72972d4c Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Mon, 5 May 2025 14:52:07 +0100 Subject: [PATCH 11/29] Update swift runtime to 6.1.0 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b60a24a..ba01dcd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.0.3-amazonlinux2 as builder +FROM swift:6.1.0-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make From 6b635ca7149d86ce9c535c70b24536e9df0ddcf9 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Mon, 5 May 2025 15:14:44 +0100 Subject: [PATCH 12/29] Update Swift to 6.1.0 on CI --- .github/workflows/meterian.yml | 2 -- .github/workflows/swift-test.yml | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml index ec2dafc..dcb7f60 100644 --- a/.github/workflows/meterian.yml +++ b/.github/workflows/meterian.yml @@ -10,8 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: swift-actions/setup-swift@v2 - with: - swift-version: "6.0.3" - name: Get swift version run: swift --version - name: Checkout diff --git a/.github/workflows/swift-test.yml b/.github/workflows/swift-test.yml index c9ce171..71cb777 100644 --- a/.github/workflows/swift-test.yml +++ b/.github/workflows/swift-test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: image: - - swift:6.0.3 + - swift:6.1.0 services: localstack: image: localstack/localstack From 61effed30a280840e3c4177d1ca24c5ae3790516 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 24 May 2025 19:05:10 +0100 Subject: [PATCH 13/29] Simplify Implementation - Graceful Shutdown Not Working --- Package.swift | 21 ----- .../BreezeDemoApplication.swift | 2 +- .../BreezeDynamoDBService.swift | 85 +++++++++---------- .../BreezeHTTPClientConfig.swift} | 21 ++--- .../BreezeHTTPClientService.swift | 57 ------------- Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift | 27 ++---- .../BreezeLambdaAPI/BreezeLambdaService.swift | 30 ++++--- .../BreezeHTTPClientServiceTests.swift | 48 ----------- .../BreezeLambdaAPIServiceTests.swift | 12 +-- .../BreezeLambdaHandlerTests.swift | 1 - Tests/BreezeLambdaAPITests/Lambda.swift | 1 - 11 files changed, 86 insertions(+), 219 deletions(-) rename Sources/{BreezeHTTPClientService/BreezeClientServiceConfig.swift => BreezeDynamoDBService/BreezeHTTPClientConfig.swift} (73%) delete mode 100644 Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift delete mode 100644 Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift diff --git a/Package.swift b/Package.swift index eec0984..bf2159d 100644 --- a/Package.swift +++ b/Package.swift @@ -16,10 +16,6 @@ let package = Package( name: "BreezeDynamoDBService", targets: ["BreezeDynamoDBService"] ), - .library( - name: "BreezeHTTPClientService", - targets: ["BreezeHTTPClientService"] - ), .library( name: "BreezeLambdaAPI", targets: ["BreezeLambdaAPI"] @@ -35,7 +31,6 @@ let package = Package( .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), .package(url: "https://github.com/soto-project/soto.git", from: "7.0.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.24.0"), ], targets: [ .executableTarget( @@ -44,20 +39,12 @@ let package = Package( "BreezeLambdaAPI" ] ), - .target( - name: "BreezeHTTPClientService", - dependencies: [ - .product(name: "AsyncHTTPClient", package: "async-http-client"), - .product(name: "Logging", package: "swift-log") - ] - ), .target( name: "BreezeDynamoDBService", dependencies: [ .product(name: "SotoDynamoDB", package: "soto"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "Logging", package: "swift-log"), - "BreezeHTTPClientService" ] ), .target( @@ -87,14 +74,6 @@ let package = Package( .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "BreezeDynamoDBService" ] - ), - .testTarget( - name: "BreezeHTTPClientServiceTests", - dependencies: [ - .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), - .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), - "BreezeHTTPClientService" - ] ) ] ) diff --git a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift index fa520cd..1964ffc 100644 --- a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift +++ b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift @@ -48,7 +48,7 @@ struct APIConfiguration: APIConfiguring { struct BreezeDemoApplication { static func main() async throws { do { - let lambdaAPIService = try BreezeLambdaAPI(apiConfig: APIConfiguration()) + let lambdaAPIService = try await BreezeLambdaAPI(apiConfig: APIConfiguration()) try await lambdaAPIService.run() } catch { print(error.localizedDescription) diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index bfcb695..6cab901 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -13,75 +13,74 @@ // limitations under the License. import SotoDynamoDB +import AsyncHTTPClient import ServiceLifecycle -import BreezeHTTPClientService import Logging -public protocol BreezeDynamoDBServing: Actor, Service { +public protocol BreezeDynamoDBServing: Actor { func dbManager() async -> BreezeDynamoDBManaging + func gracefulShutdown() throws } public actor BreezeDynamoDBService: BreezeDynamoDBServing { - private var _dbManager: BreezeDynamoDBManaging? + private let dbManager: BreezeDynamoDBManaging private let config: BreezeDynamoDBConfig - private let serviceConfig: BreezeClientServiceConfig + private let httpConfig: BreezeHTTPClientConfig + private let logger: Logger private let DBManagingType: BreezeDynamoDBManaging.Type + private var awsClient: AWSClient + private let httpClient: HTTPClient - public func dbManager() async -> BreezeDynamoDBManaging { - if let _dbManager { - return _dbManager - } - let httpClient = await serviceConfig.httpClientService.httpClient - let awsClient = AWSClient(httpClient: httpClient) - self.awsClient = awsClient + public init( + config: BreezeDynamoDBConfig, + httpConfig: BreezeHTTPClientConfig, + logger: Logger, + DBManagingType: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self + ) async { + logger.info("Init DynamoDBService with config...") + logger.info("region: \(config.region)") + logger.info("tableName: \(config.tableName)") + logger.info("keyName: \(config.keyName)") + + self.config = config + self.httpConfig = httpConfig + self.logger = logger + self.DBManagingType = DBManagingType + let timeout = HTTPClient.Configuration.Timeout( + connect: httpConfig.timeout, + read: httpConfig.timeout + ) + let configuration = HTTPClient.Configuration(timeout: timeout) + self.httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: configuration + ) + self.awsClient = AWSClient(httpClient: httpClient) let db = SotoDynamoDB.DynamoDB( client: awsClient, region: config.region, endpoint: config.endpoint ) - let dbManager = DBManagingType.init( + self.dbManager = DBManagingType.init( db: db, tableName: config.tableName, keyName: config.keyName ) - _dbManager = dbManager - return dbManager - } - - public init( - config: BreezeDynamoDBConfig, - serviceConfig: BreezeClientServiceConfig, - DBManagingType: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self - ) { - self.config = config - self.serviceConfig = serviceConfig - self.DBManagingType = DBManagingType + logger.info("DBManager is ready.") } - private var awsClient: AWSClient? - - private var logger: Logger { - serviceConfig.logger + public func dbManager() async -> BreezeDynamoDBManaging { + self.dbManager } - public func run() async throws { - logger.info("Starting DynamoDBService...") - logger.info("DynamoDBService is running with config...") - logger.info("region: \(config.region)") - logger.info("tableName: \(config.tableName)") - logger.info("keyName: \(config.keyName)") - - try await gracefulShutdown() - + public func gracefulShutdown() throws { logger.info("Stopping DynamoDBService...") - try await awsClient?.shutdown() - self.awsClient = nil + try awsClient.syncShutdown() logger.info("DynamoDBService is stopped.") - } - - deinit { - try? awsClient?.syncShutdown() + logger.info("Stopping HTTPClient...") + try httpClient.syncShutdown() + logger.info("HTTPClient is stopped.") } } diff --git a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift b/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift similarity index 73% rename from Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift rename to Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift index 25579d4..191fbad 100644 --- a/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift +++ b/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift @@ -13,17 +13,18 @@ // limitations under the License. import Logging +import NIOCore -public struct BreezeClientServiceConfig: Sendable { - - public let httpClientService: BreezeHTTPClientServing - public let logger: Logger - - public init( - httpClientService: BreezeHTTPClientServing, - logger: Logger - ) { - self.httpClientService = httpClientService +public enum BreezeClientServiceError: Error { + case invalidHttpClient +} + +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/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift deleted file mode 100644 index 1a31b26..0000000 --- a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift +++ /dev/null @@ -1,57 +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/BreezeLambdaAPI/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index 7364ef9..798642f 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift @@ -15,33 +15,31 @@ import SotoDynamoDB import ServiceLifecycle import BreezeDynamoDBService -import BreezeHTTPClientService import AWSLambdaRuntime public actor BreezeLambdaAPI: Service { let logger = Logger(label: "service-group-breeze-lambda-api") let timeout: TimeAmount - let httpClientService: BreezeHTTPClientServing let dynamoDBService: BreezeDynamoDBServing let breezeLambdaService: BreezeLambdaService private let serviceGroup: ServiceGroup private let apiConfig: any APIConfiguring - public init(apiConfig: APIConfiguring = BreezeAPIConfiguration()) throws { + public init(apiConfig: APIConfiguring = BreezeAPIConfiguration()) async throws { do { self.apiConfig = apiConfig self.timeout = .seconds(apiConfig.dbTimeout) - self.httpClientService = BreezeHTTPClientService( - timeout: timeout, + let config = try apiConfig.getConfig() + let httpConfig = BreezeHTTPClientConfig( + timeout: .seconds(60), logger: logger ) - let config = try apiConfig.getConfig() - let serviceConfig = BreezeClientServiceConfig( - httpClientService: httpClientService, + self.dynamoDBService = await BreezeDynamoDBService( + config: config, + httpConfig: httpConfig, logger: logger ) - self.dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig) self.breezeLambdaService = BreezeLambdaService( dynamoDBService: dynamoDBService, operation: try apiConfig.operation(), @@ -50,22 +48,13 @@ public actor BreezeLambdaAPI: Service { self.serviceGroup = ServiceGroup( configuration: .init( services: [ - .init( - service: httpClientService, - successTerminationBehavior: .ignore, - failureTerminationBehavior: .gracefullyShutdownGroup - ), - .init( - service: dynamoDBService, - successTerminationBehavior: .gracefullyShutdownGroup, - failureTerminationBehavior: .gracefullyShutdownGroup - ), .init( service: breezeLambdaService, successTerminationBehavior: .gracefullyShutdownGroup, failureTerminationBehavior: .gracefullyShutdownGroup ) ], + gracefulShutdownSignals: [.sigterm], logger: logger ) ) diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index f3f71a6..2181250 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -40,18 +40,24 @@ actor BreezeLambdaService: Service { } func run() async throws { - do { - logger.info("Starting BreezeLambdaService...") - let dbManager = await dynamoDBService.dbManager() - let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: operation) - self.breezeApi = breezeApi - logger.info("Starting BreezeLambdaService...") - let runtime = LambdaRuntime(body: handler) - try await runtime.run() - logger.info("BreezeLambdaService stopped.") - } catch { - logger.error("\(error.localizedDescription)") - throw error + let dbManager = await dynamoDBService.dbManager() + try await withGracefulShutdownHandler { + do { + let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: operation) + self.breezeApi = breezeApi + logger.info("Starting BreezeLambdaService...") + logger.info("Starting BreezeLambdaService...") + let runtime = LambdaRuntime(body: handler) + try await runtime.run() + logger.info("BreezeLambdaService stopped.") + } catch { + logger.error("\(error.localizedDescription)") + throw error + } + } onGracefulShutdown: { + Task { + try await self.dynamoDBService.gracefulShutdown() + } } } } diff --git a/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift deleted file mode 100644 index c6621cd..0000000 --- a/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift +++ /dev/null @@ -1,48 +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 BreezeHTTPClientService -import Logging -import Testing -import ServiceLifecycle -import ServiceLifecycleTestKit - -@Suite -struct BreezeHTTPClientServiceTests { - - let logger = Logger(label: "BreezeHTTPClientServiceTests") - - @Test - func test_breezeHTTPClientServiceGracefulShutdown() async throws { - try await testGracefulShutdown { gracefulShutdownTestTrigger in - try await withThrowingTaskGroup(of: Void.self) { group in - let sut = BreezeHTTPClientService(timeout: .seconds(1), logger: logger) - group.addTask { - try await withGracefulShutdownHandler { - try await sut.run() - let httpClient = await sut.httpClient - #expect(httpClient != nil) - } onGracefulShutdown: { - logger.info("Performing onGracefulShutdown") - } - } - group.addTask { - try await Task.sleep(nanoseconds: 10_000_000) - gracefulShutdownTestTrigger.triggerGracefulShutdown() - } - try await group.waitForAll() - } - } - } -} diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift index 1917d4c..7bdb3ca 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift @@ -39,7 +39,11 @@ struct BreezeLambdaAPIServiceTests { func test_breezeLambdaAPIService_whenValidEnvironment() async throws { try await testGracefulShutdown { gracefulShutdownTestTrigger in try await withThrowingTaskGroup(of: Void.self) { group in - let sut = try BreezeLambdaAPI(apiConfig: APIConfiguration()) + let sut = try await BreezeLambdaAPI(apiConfig: APIConfiguration()) + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } group.addTask { try await withGracefulShutdownHandler { try await sut.run() @@ -47,10 +51,6 @@ struct BreezeLambdaAPIServiceTests { logger.info("On Graceful Shutdown") } } - group.addTask { - try await Task.sleep(nanoseconds: 1_000_000_000) - gracefulShutdownTestTrigger.triggerGracefulShutdown() - } group.cancelAll() } } @@ -61,7 +61,7 @@ struct BreezeLambdaAPIServiceTests { await #expect(throws: BreezeLambdaAPIError.self) { try await testGracefulShutdown { gracefulShutdownTestTrigger in try await withThrowingTaskGroup(of: Void.self) { group in - let sut = try BreezeLambdaAPI() + let sut = try await BreezeLambdaAPI() group.addTask { try await withGracefulShutdownHandler { try await sut.run() diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index e1eda57..3e76f9a 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -17,7 +17,6 @@ import AWSLambdaEvents import ServiceLifecycle import ServiceLifecycleTestKit import BreezeDynamoDBService -import BreezeHTTPClientService @testable import BreezeLambdaAPI import Testing import Logging diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index a6da1cf..aa9889e 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -15,7 +15,6 @@ import AWSLambdaEvents @testable import AWSLambdaRuntime import BreezeDynamoDBService -import BreezeHTTPClientService @testable import BreezeLambdaAPI import Logging import NIO From c212869b3e699ecb9919bdbaa853ed9358c47411 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Thu, 10 Jul 2025 08:22:11 +0100 Subject: [PATCH 14/29] Ensure the executable terminates after a graceful shutdown --- Makefile | 11 ++++++++ Package.resolved | 4 +-- Package.swift | 2 +- .../BreezeDynamoDBService.swift | 11 +++----- .../Foundation+Extension.swift | 4 +++ Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift | 25 +++++-------------- .../BreezeLambdaAPI/BreezeLambdaService.swift | 9 ++++++- .../BreezeLambdaAPIServiceTests.swift | 15 ++++++++++- Tests/BreezeLambdaAPITests/Lambda.swift | 6 ++++- 9 files changed, 54 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index 65c6875..e145e16 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,17 @@ composer_down: localstack: docker run -it --rm -p "4566:4566" localstack/localstack +local_setup_dynamo_db: + aws --endpoint-url=http://localhost:4566 dynamodb create-table \ + --table-name Breeze \ + --attribute-definitions AttributeName=itemKey,AttributeType=S \ + --key-schema AttributeName=itemKey,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST + --region us-east-1 + +local_invoke_demo_app: + curl -X POST 127.0.0.1:7000/invoke -H "Content-Type: application/json" -d @Tests/BreezeLambdaAPITests/Fixtures/post_products_api_gtw.json + test: swift test --sanitize=thread --enable-code-coverage diff --git a/Package.resolved b/Package.resolved index c9e4002..a14a2f7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "672f8141a46f0621c29e23ab11f1efa73c11bdecea3447837c9f1766e045082a", + "originHash" : "cfd64613639f16e5d746e97a837e99292baf72475b0d78f40b33e4990d4251d9", "pins" : [ { "identity" : "async-http-client", @@ -85,7 +85,7 @@ { "identity" : "swift-aws-lambda-runtime", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-aws-lambda-runtime.git", + "location" : "https://github.com/andrea-scuderi/swift-aws-lambda-runtime.git", "state" : { "branch" : "main", "revision" : "5924fb6e75b76d45bf427e02c0017d733b235903" diff --git a/Package.swift b/Package.swift index bf2159d..ffccc29 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), + .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/swift-service-lifecycle.git", from: "2.0.0"), .package(url: "https://github.com/soto-project/soto.git", from: "7.0.0"), diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index 6cab901..c7fe3dc 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -25,10 +25,7 @@ public protocol BreezeDynamoDBServing: Actor { public actor BreezeDynamoDBService: BreezeDynamoDBServing { private let dbManager: BreezeDynamoDBManaging - private let config: BreezeDynamoDBConfig - private let httpConfig: BreezeHTTPClientConfig private let logger: Logger - private let DBManagingType: BreezeDynamoDBManaging.Type private var awsClient: AWSClient private let httpClient: HTTPClient @@ -42,11 +39,8 @@ public actor BreezeDynamoDBService: BreezeDynamoDBServing { logger.info("region: \(config.region)") logger.info("tableName: \(config.tableName)") logger.info("keyName: \(config.keyName)") - - self.config = config - self.httpConfig = httpConfig self.logger = logger - self.DBManagingType = DBManagingType + let timeout = HTTPClient.Configuration.Timeout( connect: httpConfig.timeout, read: httpConfig.timeout @@ -71,7 +65,8 @@ public actor BreezeDynamoDBService: BreezeDynamoDBServing { } public func dbManager() async -> BreezeDynamoDBManaging { - self.dbManager + logger.info("Starting DynamoDBService...") + return self.dbManager } public func gracefulShutdown() throws { diff --git a/Sources/BreezeDynamoDBService/Foundation+Extension.swift b/Sources/BreezeDynamoDBService/Foundation+Extension.swift index 63ac42c..87e6487 100644 --- a/Sources/BreezeDynamoDBService/Foundation+Extension.swift +++ b/Sources/BreezeDynamoDBService/Foundation+Extension.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 extension DateFormatter { static var iso8061: DateFormatter { diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index 798642f..e0a22f0 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift @@ -21,8 +21,6 @@ public actor BreezeLambdaAPI: Service { let logger = Logger(label: "service-group-breeze-lambda-api") let timeout: TimeAmount - let dynamoDBService: BreezeDynamoDBServing - let breezeLambdaService: BreezeLambdaService private let serviceGroup: ServiceGroup private let apiConfig: any APIConfiguring @@ -35,28 +33,20 @@ public actor BreezeLambdaAPI: Service { timeout: .seconds(60), logger: logger ) - self.dynamoDBService = await BreezeDynamoDBService( + let dynamoDBService = await BreezeDynamoDBService( config: config, httpConfig: httpConfig, logger: logger ) - self.breezeLambdaService = BreezeLambdaService( + let breezeLambdaService = BreezeLambdaService( dynamoDBService: dynamoDBService, operation: try apiConfig.operation(), logger: logger ) self.serviceGroup = ServiceGroup( - configuration: .init( - services: [ - .init( - service: breezeLambdaService, - successTerminationBehavior: .gracefullyShutdownGroup, - failureTerminationBehavior: .gracefullyShutdownGroup - ) - ], - gracefulShutdownSignals: [.sigterm], - logger: logger - ) + services: [breezeLambdaService], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: logger ) } catch { logger.error("\(error.localizedDescription)") @@ -65,10 +55,7 @@ public actor BreezeLambdaAPI: Service { } public func run() async throws { - logger.info("Starting BreezeLambdaAPIService...") + logger.info("Starting BreezeLambdaAPI...") try await serviceGroup.run() - logger.info("Stopping BreezeLambdaAPIService...") - try await gracefulShutdown() - logger.info("BreezeLambdaAPIService is stopped.") } } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index 2181250..8434a1e 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -19,6 +19,11 @@ import BreezeDynamoDBService import AWSLambdaRuntime import AWSLambdaEvents import Logging +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif actor BreezeLambdaService: Service { @@ -49,14 +54,16 @@ actor BreezeLambdaService: Service { logger.info("Starting BreezeLambdaService...") let runtime = LambdaRuntime(body: handler) try await runtime.run() - logger.info("BreezeLambdaService stopped.") } catch { logger.error("\(error.localizedDescription)") throw error } } onGracefulShutdown: { Task { + self.logger.info("Gracefully stoping BreezeLambdaService ...") try await self.dynamoDBService.gracefulShutdown() + self.logger.info("BreezeLambdaService stopped.") + exit(EXIT_SUCCESS) } } } diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift index 7bdb3ca..2f2b2e5 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift @@ -35,9 +35,15 @@ struct BreezeLambdaAPIServiceTests { let logger = Logger(label: "BreezeHTTPClientServiceTests") + /* + This test is commented out because it requires to manage the process exit(EXIT_SUCCESS). + @Test func test_breezeLambdaAPIService_whenValidEnvironment() async throws { try await testGracefulShutdown { gracefulShutdownTestTrigger in + + let (gracefulStream, continuation) = AsyncStream.makeStream() + try await withThrowingTaskGroup(of: Void.self) { group in let sut = try await BreezeLambdaAPI(apiConfig: APIConfiguration()) group.addTask { @@ -47,14 +53,21 @@ struct BreezeLambdaAPIServiceTests { group.addTask { try await withGracefulShutdownHandler { try await sut.run() + print("BreezeLambdaAPIService started successfully") } onGracefulShutdown: { logger.info("On Graceful Shutdown") + continuation.yield() + continuation.finish() } } - group.cancelAll() + for await _ in gracefulStream { + logger.info("Graceful shutdown stream received") + await group.cancelAll() + } } } } + */ @Test func test_breezeLambdaAPIService_whenInvalidEnvironment() async throws { diff --git a/Tests/BreezeLambdaAPITests/Lambda.swift b/Tests/BreezeLambdaAPITests/Lambda.swift index aa9889e..e5d45b0 100644 --- a/Tests/BreezeLambdaAPITests/Lambda.swift +++ b/Tests/BreezeLambdaAPITests/Lambda.swift @@ -20,11 +20,15 @@ import Logging import NIO import ServiceLifecycle import ServiceLifecycleTestKit -import Foundation import Logging import Testing import SotoDynamoDB import AsyncHTTPClient +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif extension AWSLambdaRuntime.Lambda { From 6c564c582da7281072ebf804d458cee39015e908 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Fri, 11 Jul 2025 17:57:52 +0100 Subject: [PATCH 15/29] Fix Graceful shutdown --- Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift | 1 + .../BreezeLambdaAPI/BreezeLambdaService.swift | 39 +++++++++++++------ .../BreezeLambdaAPIServiceTests.swift | 6 +-- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index e0a22f0..027358d 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift @@ -57,5 +57,6 @@ public actor BreezeLambdaAPI: Service { public func run() async throws { logger.info("Starting BreezeLambdaAPI...") try await serviceGroup.run() + logger.info("BreezeLambdaAPI is stopped successfully") } } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index 8434a1e..505999b 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -46,25 +46,40 @@ actor BreezeLambdaService: Service { func run() async throws { let dbManager = await dynamoDBService.dbManager() - try await withGracefulShutdownHandler { + let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: self.operation) + self.breezeApi = breezeApi + logger.info("Starting BreezeLambdaService...") + let runtime = LambdaRuntime(body: handler) + try await runTaskWithCancellationOnGracefulShutdown { do { - let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: operation) - self.breezeApi = breezeApi - logger.info("Starting BreezeLambdaService...") - logger.info("Starting BreezeLambdaService...") - let runtime = LambdaRuntime(body: handler) try await runtime.run() } catch { - logger.error("\(error.localizedDescription)") + self.logger.error("\(error.localizedDescription)") throw error } } onGracefulShutdown: { - Task { - self.logger.info("Gracefully stoping BreezeLambdaService ...") - try await self.dynamoDBService.gracefulShutdown() - self.logger.info("BreezeLambdaService stopped.") - exit(EXIT_SUCCESS) + self.logger.info("Gracefully stoping BreezeLambdaService ...") + try await self.dynamoDBService.gracefulShutdown() + self.logger.info("BreezeLambdaService is stopped.") + } + } + + 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() + } } } diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift index 2f2b2e5..bd55e44 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift @@ -35,9 +35,6 @@ struct BreezeLambdaAPIServiceTests { let logger = Logger(label: "BreezeHTTPClientServiceTests") - /* - This test is commented out because it requires to manage the process exit(EXIT_SUCCESS). - @Test func test_breezeLambdaAPIService_whenValidEnvironment() async throws { try await testGracefulShutdown { gracefulShutdownTestTrigger in @@ -62,12 +59,11 @@ struct BreezeLambdaAPIServiceTests { } for await _ in gracefulStream { logger.info("Graceful shutdown stream received") - await group.cancelAll() + group.cancelAll() } } } } - */ @Test func test_breezeLambdaAPIService_whenInvalidEnvironment() async throws { From a37c362bfdde508c20ab8e44084cc50374f68c88 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Fri, 11 Jul 2025 20:44:03 +0100 Subject: [PATCH 16/29] Fix Unit Tests --- Sources/BreezeDynamoDBService/Foundation+Extension.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/BreezeDynamoDBService/Foundation+Extension.swift b/Sources/BreezeDynamoDBService/Foundation+Extension.swift index 87e6487..9dbbfb2 100644 --- a/Sources/BreezeDynamoDBService/Foundation+Extension.swift +++ b/Sources/BreezeDynamoDBService/Foundation+Extension.swift @@ -12,11 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif +import class Foundation.DateFormatter +import struct Foundation.Date +import struct Foundation.TimeZone extension DateFormatter { static var iso8061: DateFormatter { From 8cdc3918ad1e04a6f65fd627943d66fb405c0fc4 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Fri, 11 Jul 2025 20:44:29 +0100 Subject: [PATCH 17/29] Fix Meterian --- .github/workflows/meterian.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml index dcb7f60..e3ef994 100644 --- a/.github/workflows/meterian.yml +++ b/.github/workflows/meterian.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: swift-actions/setup-swift@v2 + with: + swift-version: '6.1.0' - name: Get swift version run: swift --version - name: Checkout From 48a61d47b3801240156df1af29a0b01e1eff5a25 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 10:24:46 +0100 Subject: [PATCH 18/29] Improve Unit Tests by fixing warnings --- .../BreezeDynamoDBManagerMock.swift | 86 +++++++++++++++++++ .../BreezeDynamoDBManagerTests.swift | 41 +++++---- .../BreezeDynamoDBServiceTests.swift | 72 ++++++++++++++++ .../BreezeDynamoDBManagerMock.swift | 18 ++-- .../BreezeLambdaHandlerTests.swift | 6 +- 5 files changed, 194 insertions(+), 29 deletions(-) create mode 100644 Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift create mode 100644 Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift new file mode 100644 index 0000000..0d5ffa2 --- /dev/null +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift @@ -0,0 +1,86 @@ +// 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 BreezeDynamoDBService +@testable import BreezeLambdaAPI +import SotoDynamoDB + +actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { + let keyName: String + + enum BreezeDynamoDBManagerError: Error { + case invalidRequest + case invalidItem + } + + + private var response: (any BreezeCodable)? + private var keyedResponse: (any BreezeCodable)? + + func setupMockResponse(response: (any BreezeCodable)?, keyedResponse: (any BreezeCodable)?) { + self.keyedResponse = keyedResponse + self.response = response + } + + init(db: SotoDynamoDB.DynamoDB, tableName: String, keyName: String) { + self.keyName = keyName + } + + func createItem(item: T) async throws -> T { + guard let response = self.response as? T else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return response + } + + func readItem(key: String) async throws -> T { + guard let response = self.keyedResponse as? T, + response.key == key + else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return response + } + + func updateItem(item: T) async throws -> T { + guard let response = self.keyedResponse as? T, + response.key == item.key + else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return response + } + + func deleteItem(item: T) async throws { + guard let response = self.keyedResponse, + response.key == item.key, + response.createdAt == item.createdAt, + response.updatedAt == item.updatedAt + else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return + } + + var limit: Int? + var exclusiveKey: String? + func listItems(key: String?, limit: Int?) async throws -> ListResponse { + guard let response = self.response as? T else { + throw BreezeDynamoDBManagerError.invalidItem + } + self.limit = limit + self.exclusiveKey = key + return ListResponse(items: [response], lastEvaluatedKey: key) + } +} diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift index 0d2d775..7e16164 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift @@ -73,9 +73,10 @@ struct BreezeDynamoDBManagerTests { try #require(value.updatedAt?.iso8601 != nil) do { _ = try await sut.createItem(item: product2023) - Issue.record("It should throw conditionalCheckFailedException") + Issue.record("It should throw DynamoDBErrorType.conditionalCheckFailedException") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) } try await removeTable(tableName: uuid) } @@ -102,9 +103,10 @@ struct BreezeDynamoDBManagerTests { #expect(value.key == "2023") do { let _: Product = try await sut.readItem(key: "2022") - Issue.record("It should throw when Item is missing") + Issue.record("It should throw ServiceError.notfound when Item is missing") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? BreezeDynamoDBManager.ServiceError) + #expect(dynamoDBError == .notFound) } try await removeTable(tableName: uuid) } @@ -140,16 +142,18 @@ struct BreezeDynamoDBManagerTests { #expect(value.updatedAt?.iso8601 != newValue.updatedAt?.iso8601) do { let _: Product = try await sut.updateItem(item: product2023) - Issue.record("It should throw conditionalCheckFailedException") + Issue.record("It should throw AWSResponseError ValidationException") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? AWSResponseError) + #expect(dynamoDBError.errorCode == "ValidationException") } do { let _: Product = try await sut.updateItem(item: product2022) - Issue.record("It should throw conditionalCheckFailedException") + Issue.record("It should throw AWSResponseError ValidationException") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? AWSResponseError) + #expect(dynamoDBError.errorCode == "ValidationException") } try await removeTable(tableName: uuid) } @@ -171,9 +175,10 @@ struct BreezeDynamoDBManagerTests { let sut = try await givenTable(tableName: uuid) do { try await sut.deleteItem(item: product2022) - Issue.record("It should throw ServiceError.missingParameters") + Issue.record("It should throw DynamoDBErrorType.conditionalCheckFailedException") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) } try await removeTable(tableName: uuid) } @@ -189,7 +194,8 @@ struct BreezeDynamoDBManagerTests { try await sut.deleteItem(item: value) Issue.record("It should throw ServiceError.missingParameters") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? BreezeDynamoDBManager.ServiceError) + #expect(dynamoDBError == .missingParameters) } try await removeTable(tableName: uuid) } @@ -205,7 +211,8 @@ struct BreezeDynamoDBManagerTests { try await sut.deleteItem(item: value) Issue.record("It should throw ServiceError.missingParameters") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? BreezeDynamoDBManager.ServiceError) + #expect(dynamoDBError == .missingParameters) } try await removeTable(tableName: uuid) } @@ -219,9 +226,10 @@ struct BreezeDynamoDBManagerTests { value.updatedAt = Date().iso8601 do { try await sut.deleteItem(item: value) - Issue.record("It should throw ServiceError.missingParameters") + Issue.record("It should throw DynamoDBErrorType.conditionalCheckFailedException") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) } try await removeTable(tableName: uuid) } @@ -235,9 +243,10 @@ struct BreezeDynamoDBManagerTests { value.createdAt = Date().iso8601 do { try await sut.deleteItem(item: value) - Issue.record("It should throw ServiceError.missingParameters") + Issue.record("It should throw DynamoDBErrorType.conditionalCheckFailedException") } catch { - try #require(error != nil) + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) } try await removeTable(tableName: uuid) } diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift new file mode 100644 index 0000000..1c0509c --- /dev/null +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift @@ -0,0 +1,72 @@ +// 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 SotoDynamoDB +import AsyncHTTPClient +import Logging +import Testing +@testable import BreezeDynamoDBService + +@Suite +struct BreezeDynamoDBServiceTests { + @Test + func testInitPrepareBreezeDynamoDBManager() async throws { + let sut = await makeBreezeDynamoDBConfig() + let manager = await sut.dbManager() + #expect(manager is BreezeDynamoDBManager, "Expected BreezeDynamoDBManager instance") + try await sut.gracefulShutdown() + } + + @Test + func testGracefulShutdownCanBeCalledMultipleTimes() async throws { + let sut = await makeBreezeDynamoDBConfig() + try await sut.gracefulShutdown() + try await sut.gracefulShutdown() + } + + @Test + func testMockInjection() async throws { + let config = BreezeDynamoDBConfig( + region: .useast1, + tableName: "TestTable", + keyName: "TestKey", + ) + let logger = Logger(label: "BreezeDynamoDBServiceTests") + let httpConfig = BreezeHTTPClientConfig(timeout: .seconds(10), logger: logger) + let sut = await BreezeDynamoDBService( + config: config, + httpConfig: httpConfig, + logger: logger, + DBManagingType: BreezeDynamoDBManagerMock.self + ) + let manager = await sut.dbManager() + #expect(manager is BreezeDynamoDBManagerMock, "Expected BreezeDynamoDBManager instance") + try await sut.gracefulShutdown() + } + + private func makeBreezeDynamoDBConfig() async -> BreezeDynamoDBService { + let config = BreezeDynamoDBConfig( + region: .useast1, + tableName: "TestTable", + keyName: "TestKey", + ) + let logger = Logger(label: "BreezeDynamoDBServiceTests") + let httpConfig = BreezeHTTPClientConfig(timeout: .seconds(10), logger: logger) + return await BreezeDynamoDBService( + config: config, + httpConfig: httpConfig, + logger: logger, + ) + } +} diff --git a/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift b/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift index 33297d5..a6b0499 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift +++ b/Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift @@ -18,26 +18,26 @@ import SotoDynamoDB actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { let keyName: String - + private var response: (any BreezeCodable)? private var keyedResponse: (any BreezeCodable)? func setupMockResponse(response: (any BreezeCodable)?, keyedResponse: (any BreezeCodable)?) { - self.keyedResponse = keyedResponse - self.response = response + self.keyedResponse = keyedResponse + self.response = response } - + init(db: SotoDynamoDB.DynamoDB, tableName: String, keyName: String) { self.keyName = keyName } - + func createItem(item: T) async throws -> T { guard let response = self.response as? T else { throw BreezeLambdaAPIError.invalidRequest } return response } - + func readItem(key: String) async throws -> T { guard let response = self.keyedResponse as? T, response.key == key @@ -46,7 +46,7 @@ actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { } return response } - + func updateItem(item: T) async throws -> T { guard let response = self.keyedResponse as? T, response.key == item.key @@ -55,7 +55,7 @@ actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { } return response } - + func deleteItem(item: T) async throws { guard let response = self.keyedResponse, response.key == item.key, @@ -66,7 +66,7 @@ actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { } return } - + var limit: Int? var exclusiveKey: String? func listItems(key: String?, limit: Int?) async throws -> ListResponse { diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift index 3e76f9a..a245144 100644 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -222,10 +222,9 @@ struct BreezeLambdaHandlerTests { keyedResponse: keyedResponse, with: request ) - let response: BreezeEmptyResponse = try apiResponse.decodeBody() + let _: BreezeEmptyResponse = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .ok) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response != nil) } @Test @@ -241,10 +240,9 @@ struct BreezeLambdaHandlerTests { keyedResponse: keyedResponse, with: request ) - let response: BreezeEmptyResponse = try apiResponse.decodeBody() + let _: BreezeEmptyResponse = try apiResponse.decodeBody() #expect(apiResponse.statusCode == .notFound) #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) - #expect(response != nil) } @Test From 292e8f147aff831f3149c3f66365bf81c70461b5 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 10:25:01 +0100 Subject: [PATCH 19/29] Improve Documentation --- .../BreezeDynamoDBService/BreezeCodable.swift | 9 ++++ .../BreezeDynamoDBConfig.swift | 16 +++++++ .../BreezeDynamoDBManager.swift | 43 ++++++++++++++++- .../BreezeDynamoDBManaging.swift | 48 +++++++++++++++++++ .../BreezeDynamoDBService.swift | 22 ++++++++- .../BreezeHTTPClientConfig.swift | 10 ++++ .../Foundation+Extension.swift | 4 ++ .../BreezeDynamoDBService/ListResponse.swift | 14 ++++++ .../APIGatewayV2Response+Extensions.swift | 6 +-- .../BreezeAPIConfiguration.swift | 43 +++++++++++++++++ .../BreezeLambdaAPI/BreezeEmptyResponse.swift | 1 + Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift | 20 +++++++- .../BreezeLambdaAPIError.swift | 8 ++++ .../BreezeLambdaAPI/BreezeLambdaHandler.swift | 21 ++++++++ .../BreezeLambdaAPI/BreezeLambdaService.swift | 19 ++++++++ Sources/BreezeLambdaAPI/BreezeOperation.swift | 7 +++ 16 files changed, 284 insertions(+), 7 deletions(-) diff --git a/Sources/BreezeDynamoDBService/BreezeCodable.swift b/Sources/BreezeDynamoDBService/BreezeCodable.swift index 93077f1..ccb0006 100644 --- a/Sources/BreezeDynamoDBService/BreezeCodable.swift +++ b/Sources/BreezeDynamoDBService/BreezeCodable.swift @@ -18,8 +18,17 @@ import FoundationEssentials import Foundation #endif +/// CodableSendable is a protocol that combines Sendable and Codable. public protocol CodableSendable: Sendable, Codable { } +/// BreezeCodable is a protocol that extends CodableSendable to include properties +/// for a key, creation date, and update date. +/// It is designed to be used with Breeze services that require these common fields +/// for items stored in a database, such as DynamoDB. +/// - Parameters: +/// - key: A unique identifier for the item. +/// - createdAt: An optional string representing the creation date of the item. +/// - updatedAt: An optional string representing the last update date of the item. public protocol BreezeCodable: CodableSendable { var key: String { get set } var createdAt: String? { get set } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift index ea4b05c..40b69c9 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift @@ -14,7 +14,16 @@ import SotoCore +/// BreezeDynamoDBConfig is a configuration structure for Breeze DynamoDB service. +/// It contains the necessary parameters to connect to a DynamoDB instance, including the region, table name, key name, and an optional endpoint. public struct BreezeDynamoDBConfig: Sendable { + + /// Initializes a new instance of BreezeDynamoDBConfig. + /// - Parameters: + /// - region: The AWS region where the DynamoDB table is located. + /// - tableName: The name of the DynamoDB table. + /// - keyName: The name of the primary key in the DynamoDB table. + /// - endpoint: An optional endpoint URL for the DynamoDB service. If not provided, the default AWS endpoint will be used. public init( region: Region, tableName: String, @@ -27,8 +36,15 @@ public struct BreezeDynamoDBConfig: Sendable { self.endpoint = endpoint } + /// The AWS region where the DynamoDB table is located. public let region: Region + + /// The name of the DynamoDB table. public let tableName: String + + /// The name of the primary key in the DynamoDB table. public let keyName: String + + /// An optional endpoint URL for the DynamoDB service. public let endpoint: String? } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift index fd15eda..8e19312 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift @@ -16,16 +16,32 @@ import struct Foundation.Date import NIO import SotoDynamoDB +/// BreezeDynamoDBManager is a manager for handling DynamoDB operations in Breeze. +/// It provides methods to create, read, update, delete, and list items in a DynamoDB table. +/// It conforms to the BreezeDynamoDBManaging protocol, which defines the necessary operations for Breeze's DynamoDB integration. +/// - Note: This manager requires a DynamoDB instance, a table name, and a key name to operate. +/// It uses the SotoDynamoDB library to interact with AWS DynamoDB services. public struct BreezeDynamoDBManager: BreezeDynamoDBManaging { + + /// ServiceError defines the possible errors that can occur when interacting with the BreezeDynamoDBManager. enum ServiceError: Error { + /// Indicates that the requested item was not found in the DynamoDB table. case notFound + /// Indicates that the operation failed due to missing parameters, such as a required key. case missingParameters } - - let db: DynamoDB + + /// The name of the primary key in the DynamoDB table. public let keyName: String + + let db: DynamoDB let tableName: String + /// Initializes a new instance of BreezeDynamoDBManager. + /// - Parameters: + /// - db: The DynamoDB instance to use for operations. + /// - tableName: The name of the DynamoDB table to manage. + /// - keyName: The name of the primary key in the DynamoDB table. public init(db: DynamoDB, tableName: String, keyName: String) { self.db = db self.tableName = tableName @@ -34,6 +50,10 @@ public struct BreezeDynamoDBManager: BreezeDynamoDBManaging { } public extension BreezeDynamoDBManager { + + /// Creates a new item in the DynamoDB table. + /// - Parameter item: The item to create, conforming to the BreezeCodable protocol. + /// - Returns: The created item, with its `createdAt` and `updatedAt` timestamps set. func createItem(item: T) async throws -> T { var item = item let date = Date() @@ -49,6 +69,9 @@ public extension BreezeDynamoDBManager { return try await readItem(key: item.key) } + /// Reads an item from the DynamoDB table by its key. + /// - Parameter key: The key of the item to read. + /// - Returns: The item with the specified key, conforming to the BreezeCodable protocol. func readItem(key: String) async throws -> T { let input = DynamoDB.GetItemInput( key: [keyName: DynamoDB.AttributeValue.s(key)], @@ -65,6 +88,11 @@ public extension BreezeDynamoDBManager { let oldUpdatedAt: String } + /// Updates an existing item in the DynamoDB table. + /// - Parameter item: The item to update, conforming to the BreezeCodable protocol. + /// - Returns: The updated item, with its `updatedAt` timestamp set to the current date. + /// - Throws: An error if the item cannot be updated, such as if the item does not exist or the condition fails. + /// - Important: The update operation checks that the `updatedAt` and `createdAt` timestamps match the existing values to prevent concurrent modifications. func updateItem(item: T) async throws -> T { var item = item let oldUpdatedAt = item.updatedAt ?? "" @@ -82,6 +110,10 @@ public extension BreezeDynamoDBManager { return try await readItem(key: item.key) } + /// Deletes an item from the DynamoDB table. + /// - Parameter item: The item to delete, conforming to the BreezeCodable protocol. + /// - Throws: An error if the item cannot be deleted, such as if the item does not exist or the condition fails. + /// - Important: The `updatedAt` and `createdAt` timestamps must be set on the item to ensure safe deletion. This method checks that the `updatedAt` and `createdAt` timestamps match the existing values to prevent concurrent modifications. func deleteItem(item: T) async throws { guard let updatedAt = item.updatedAt, let createdAt = item.createdAt else { @@ -101,6 +133,13 @@ public extension BreezeDynamoDBManager { return } + /// Lists items in the DynamoDB table with optional pagination. + /// - Parameters: + /// - key: An optional key to start the listing from, useful for pagination. + /// - limit: An optional limit on the number of items to return. + /// - Returns: A `ListResponse` containing the items and the last evaluated key for pagination. + /// - Throws: An error if the listing operation fails. + /// - Important: The `key` parameter is used to continue listing from a specific point, and the `limit` parameter controls how many items are returned in one call. func listItems(key: String?, limit: Int?) async throws -> ListResponse { var exclusiveStartKey: [String: DynamoDB.AttributeValue]? if let key { diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift index b4208f8..3733b3b 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift @@ -14,12 +14,60 @@ import SotoDynamoDB +/// BreezeDynamoDBManaging is a protocol that defines the methods for managing DynamoDB items. public protocol BreezeDynamoDBManaging: Sendable { + /// The keyName is the name of the primary key in the DynamoDB table. var keyName: String { get } + /// Initializes the BreezeDynamoDBManaging with the provided DynamoDB client, table name, and key name. + /// - Parameters: + /// - db: The DynamoDB client to use for database operations. + /// - tableName: The name of the DynamoDB table. + /// - keyName: The name of the primary key in the DynamoDB table. init(db: DynamoDB, tableName: String, keyName: String) + + /// Creates a new item in the DynamoDB table. + /// - Parameter item: The item to create, conforming to BreezeCodable. + /// - Returns: The created item. + /// - Note: + /// - The item must conform to BreezeCodable. func createItem(item: Item) async throws -> Item + + /// Reads an item from the DynamoDB table. + /// - Parameter key: The key of the item to read. + /// - Returns: The item read from the table, conforming to BreezeCodable. + /// - Throws: An error if the item could not be read. + /// - Note: + /// - The key should match the primary key defined in the DynamoDB table. + /// - The item must conform to BreezeCodable. func readItem(key: String) async throws -> Item + + /// Updates an existing item in the DynamoDB table. + /// - Parameter item: The item to update, conforming to BreezeCodable. + /// - Returns: The updated item. + /// - Throws: An error if the item could not be updated. + /// - Note: + /// - The item must have the same primary key as an existing item in the table. + /// - The item must conform to BreezeCodable. func updateItem(item: Item) async throws -> Item + + /// Deletes an item from the DynamoDB table. + /// - Parameter item: The item to delete, conforming to BreezeCodable. + /// - Throws: An error if the item could not be deleted. + /// - Returns: Void if the item was successfully deleted. + /// - Note: + /// - The item must conform to BreezeCodable. + /// - The item must have the same primary key as an existing item in the table. func deleteItem(item: Item) async throws + + /// Lists items in the DynamoDB table. + /// - Parameters: + /// - key: An optional key to filter the items. + /// - limit: An optional limit on the number of items to return. + /// - Returns: A ListResponse containing the items and an optional last evaluated key. + /// - Throws: An error if the items could not be listed. + /// - Note: + /// - The items must conform to BreezeCodable. + /// - The key is used to filter the items based on the primary key defined in the DynamoDB table. + /// - The limit is used to control the number of items returned in the response. func listItems(key: String?, limit: Int?) async throws -> ListResponse } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index c7fe3dc..89e6d79 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift @@ -17,6 +17,9 @@ import AsyncHTTPClient import ServiceLifecycle import Logging +/// BreezeDynamoDBServing +/// A protocol that defines the interface for a Breeze DynamoDB service. +/// It provides methods to access the database manager and to gracefully shutdown the service. public protocol BreezeDynamoDBServing: Actor { func dbManager() async -> BreezeDynamoDBManaging func gracefulShutdown() throws @@ -26,9 +29,17 @@ public actor BreezeDynamoDBService: BreezeDynamoDBServing { private let dbManager: BreezeDynamoDBManaging private let logger: Logger - private var awsClient: AWSClient + private let awsClient: AWSClient private let httpClient: HTTPClient + private var isShutdown = false + /// Initializes the BreezeDynamoDBService with the provided configuration. + /// - Parameters: + /// - config: The configuration for the DynamoDB service. + /// - httpConfig: The configuration for the HTTP client. + /// - logger: The logger to use for logging messages. + /// - DBManagingType: The type of the BreezeDynamoDBManaging to use. Defaults to BreezeDynamoDBManager. + /// This initializer sets up the AWS client, HTTP client, and the database manager. public init( config: BreezeDynamoDBConfig, httpConfig: BreezeHTTPClientConfig, @@ -64,18 +75,27 @@ public actor BreezeDynamoDBService: BreezeDynamoDBServing { logger.info("DBManager is ready.") } + /// Returns the BreezeDynamoDBManaging instance. public func dbManager() async -> BreezeDynamoDBManaging { logger.info("Starting DynamoDBService...") return self.dbManager } + /// Gracefully shutdown the service and its components. + /// - Throws: An error if the shutdown process fails. + /// This method ensures that the AWS client and HTTP client are properly shutdown before marking the service as shutdown. + /// It also logs the shutdown process. + /// This method is idempotent; + /// - Important: This method must be called at leat once to ensure that resources are released properly. If the method is not called, it will lead to a crash. public func gracefulShutdown() throws { + guard !isShutdown else { return } logger.info("Stopping DynamoDBService...") try awsClient.syncShutdown() logger.info("DynamoDBService is stopped.") logger.info("Stopping HTTPClient...") try httpClient.syncShutdown() logger.info("HTTPClient is stopped.") + isShutdown = true } } diff --git a/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift b/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift index 191fbad..271d718 100644 --- a/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift +++ b/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift @@ -15,16 +15,26 @@ import Logging import NIOCore +/// BreezeClientServiceError defines the errors that can occur in the Breeze Client Service. public enum BreezeClientServiceError: Error { case invalidHttpClient } +/// BreezeHTTPClientConfig is a configuration structure for the Breeze HTTP client. public struct BreezeHTTPClientConfig: Sendable { + + /// Initializes a new instance of BreezeHTTPClientConfig. + /// - Parameters: + /// - timeout: The timeout duration for HTTP requests. + /// - logger: The logger to use for logging messages. public init(timeout: TimeAmount, logger: Logger) { self.timeout = timeout self.logger = logger } + /// The timeout duration for HTTP requests. public let timeout: TimeAmount + + /// The logger to use for logging messages. public let logger: Logger } diff --git a/Sources/BreezeDynamoDBService/Foundation+Extension.swift b/Sources/BreezeDynamoDBService/Foundation+Extension.swift index 9dbbfb2..89dff5f 100644 --- a/Sources/BreezeDynamoDBService/Foundation+Extension.swift +++ b/Sources/BreezeDynamoDBService/Foundation+Extension.swift @@ -16,6 +16,8 @@ import class Foundation.DateFormatter import struct Foundation.Date import struct Foundation.TimeZone +/// This file contains extensions for DateFormatter, Date, and String to handle ISO 8601 date formatting and parsing. +/// These extensions provide a convenient way to convert between `Date` objects and their ISO 8601 string representations. extension DateFormatter { static var iso8061: DateFormatter { let formatter = DateFormatter() @@ -26,6 +28,7 @@ extension DateFormatter { } extension Date { + /// Returns a string representation of the date in ISO 8601 format. var iso8601: String { let formatter = DateFormatter.iso8061 return formatter.string(from: self) @@ -33,6 +36,7 @@ extension Date { } extension String { + /// Attempts to parse the string as an ISO 8601 date. var iso8601: Date? { let formatter = DateFormatter.iso8061 return formatter.date(from: self) diff --git a/Sources/BreezeDynamoDBService/ListResponse.swift b/Sources/BreezeDynamoDBService/ListResponse.swift index 0932fe2..fa94eec 100644 --- a/Sources/BreezeDynamoDBService/ListResponse.swift +++ b/Sources/BreezeDynamoDBService/ListResponse.swift @@ -18,11 +18,25 @@ import FoundationEssentials import Foundation #endif +/// Model representing a paginated list response from a DynamoDB operation. +/// This struct contains an array of items and an optional last evaluated key for pagination. +/// This struct conforms to `CodableSendable`, allowing it to be encoded and decoded for network transmission or storage. public struct ListResponse: CodableSendable { + + /// Initializes a new instance of `ListResponse`. + /// - Parameters: + /// - items: An array of items returned from the DynamoDB operation. + /// - lastEvaluatedKey: An optional string representing the last evaluated key for pagination. If nil, it indicates that there are no more items to fetch. + /// + /// This initializer is used to create a paginated response for DynamoDB operations. public init(items: [Item], lastEvaluatedKey: String? = nil) { self.items = items self.lastEvaluatedKey = lastEvaluatedKey } + + /// The items returned from the DynamoDB operation. public let items: [Item] + + /// An optional string representing the last evaluated key for pagination. public let lastEvaluatedKey: String? } diff --git a/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift b/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift index a334f9a..5243be9 100644 --- a/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift +++ b/Sources/BreezeLambdaAPI/APIGatewayV2Response+Extensions.swift @@ -19,15 +19,15 @@ import class Foundation.JSONEncoder extension APIGatewayV2Response { private static let encoder = JSONEncoder() - /// defaultHeaders /// Override the headers in APIGatewayV2Response static let defaultHeaders = [ "Content-Type": "application/json" ] + /// A model representing the body of an error response struct BodyError: Codable { let error: String } - /// init + /// Initializer for APIGatewayV2Response with a BodyError /// - Parameters: /// - error: Error /// - statusCode: HTTP Status Code @@ -36,7 +36,7 @@ extension APIGatewayV2Response { self.init(with: bodyError, statusCode: statusCode) } - /// init + /// Initializer for APIGatewayV2Response with an Encodable object /// - Parameters: /// - object: Encodable Object /// - statusCode: HTTP Status Code diff --git a/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift b/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift index 127d6b6..7f622bf 100644 --- a/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift +++ b/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift @@ -9,18 +9,31 @@ import SotoDynamoDB import BreezeDynamoDBService import AWSLambdaRuntime +/// APIConfiguring is a protocol that defines the configuration for the Breeze Lambda API. public protocol APIConfiguring { var dbTimeout: Int64 { get } func operation() throws -> BreezeOperation func getConfig() throws -> BreezeDynamoDBConfig } +/// BreezeAPIConfiguration is a struct that conforms to APIConfiguring. +/// It provides the necessary configuration for the Breeze Lambda API, including the DynamoDB table name, key name, and AWS region. +/// It also defines the operation handler for Breeze operations. public struct BreezeAPIConfiguration: APIConfiguring { public init() {} + /// The timeout for database operations in seconds. public let dbTimeout: Int64 = 30 + /// The operation handler for Breeze operations. + /// This method retrieves the handler from the environment variable `_HANDLER`. + /// - Throws: `BreezeLambdaAPIError.invalidHandler` if the handler is not found or cannot be parsed. + /// - Returns: A `BreezeOperation` instance initialized with the handler. + /// + /// This method is used to determine the operation that will be executed by the Breeze Lambda API. + /// It expects the `_HANDLER` environment variable to be set, which should contain the handler in the format `module.function`. + /// See BreezeOperation for more details. public func operation() throws -> BreezeOperation { guard let handler = Lambda.env("_HANDLER"), let operation = BreezeOperation(handler: handler) @@ -30,6 +43,12 @@ public struct BreezeAPIConfiguration: APIConfiguring { return operation } + /// Returns the configuration for the Breeze DynamoDB service. + /// - Throws: + /// - `BreezeLambdaAPIError.tableNameNotFound` if the DynamoDB table name is not found in the environment variables. + /// - `BreezeLambdaAPIError.keyNameNotFound` if the DynamoDB key name is not found in the environment variables. + /// + /// This method retrieves the AWS region, DynamoDB table name, key name, and optional endpoint from the environment variables. public func getConfig() throws -> BreezeDynamoDBConfig { BreezeDynamoDBConfig( region: currentRegion(), @@ -39,6 +58,11 @@ public struct BreezeAPIConfiguration: APIConfiguring { ) } + /// Returns the current AWS region based on the `AWS_REGION` environment variable. + /// If the variable is not set, it defaults to `.useast1`. + /// - Returns: A `Region` instance representing the current AWS region. + /// + /// This method is used to determine the AWS region where the DynamoDB table is located. func currentRegion() -> Region { if let awsRegion = Lambda.env("AWS_REGION") { let value = Region(rawValue: awsRegion) @@ -48,6 +72,11 @@ public struct BreezeAPIConfiguration: APIConfiguring { } } + /// Returns the DynamoDB table name from the `DYNAMO_DB_TABLE_NAME` environment variable. + /// - Throws: `BreezeLambdaAPIError.tableNameNotFound` if the table name is not found in the environment variables. + /// - Returns: A `String` representing the DynamoDB table name. + /// This method is used to retrieve the name of the DynamoDB table that will be used by the Breeze Lambda API. + /// - Important: The table name is essential for performing operations on the DynamoDB table. func tableName() throws -> String { guard let tableName = Lambda.env("DYNAMO_DB_TABLE_NAME") else { throw BreezeLambdaAPIError.tableNameNotFound @@ -55,6 +84,11 @@ public struct BreezeAPIConfiguration: APIConfiguring { return tableName } + /// Returns the DynamoDB key name from the `DYNAMO_DB_KEY` environment variable. + /// - Throws: `BreezeLambdaAPIError.keyNameNotFound` if the key name is not found in the environment variables. + /// - Returns: A `String` representing the DynamoDB key name. + /// This method is used to retrieve the name of the primary key in the DynamoDB table that will be used by the Breeze Lambda API. + /// - Important: The key name is essential for identifying items in the DynamoDB table. func keyName() throws -> String { guard let tableName = Lambda.env("DYNAMO_DB_KEY") else { throw BreezeLambdaAPIError.keyNameNotFound @@ -62,6 +96,15 @@ public struct BreezeAPIConfiguration: APIConfiguring { return tableName } + /// Returns the endpoint for the Breeze Lambda API. + /// If the `LOCALSTACK_ENDPOINT` environment variable is set, it returns that value. + /// - Returns: An optional `String` representing the endpoint URL. + /// - Important: If the `LOCALSTACK_ENDPOINT` environment variable is not set, it returns `nil`, indicating that no custom endpoint is configured. + /// - Note: + /// - This method is useful for testing purposes, especially when running the Breeze Lambda API locally with LocalStack. + /// - LocalStack is a fully functional local AWS cloud stack that allows you to test AWS services locally. + /// - To set it you need to set the `LOCALSTACK_ENDPOINT` environment variable to the URL of your LocalStack instance. + /// - The Default LocalStack endpoint is `http://localhost:4566` func endpoint() -> String? { if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), !localstack.isEmpty { diff --git a/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift b/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift index 1a00b15..cfb1a85 100644 --- a/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift +++ b/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift @@ -15,4 +15,5 @@ import class Foundation.JSONDecoder import class Foundation.JSONEncoder +/// A simple struct representing an empty response for Breeze Lambda API. struct BreezeEmptyResponse: Codable {} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index 027358d..67fab77 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift @@ -17,6 +17,9 @@ import ServiceLifecycle import BreezeDynamoDBService import AWSLambdaRuntime +/// BreezeLambdaAPI is a service that integrates with AWS Lambda to provide a Breeze API for DynamoDB operations. +/// It supports operations such as create, read, update, delete, and list items in a DynamoDB table using a BreezeCodable. +/// This Service is designed to work with ServiceLifecycle, allowing it to be run and stopped gracefully. public actor BreezeLambdaAPI: Service { let logger = Logger(label: "service-group-breeze-lambda-api") @@ -24,6 +27,15 @@ public actor BreezeLambdaAPI: Service { private let serviceGroup: ServiceGroup private let apiConfig: any APIConfiguring + /// Initializes the BreezeLambdaAPI with the provided API configuration. + /// - Parameter apiConfig: An object conforming to `APIConfiguring` that provides the necessary configuration for the Breeze API. + /// - Throws: An error if the configuration is invalid or if the service fails to initialize. + /// + /// This initializer sets up the Breeze Lambda API service with the specified configuration, including the DynamoDB service and the operation to be performed. + /// + /// - Note: + /// - The `apiConfig` parameter must conform to the `APIConfiguring` protocol, which provides the necessary configuration details for the Breeze API. + /// - The default implementation uses `BreezeAPIConfiguration`, but you can provide your own implementation if needed. public init(apiConfig: APIConfiguring = BreezeAPIConfiguration()) async throws { do { self.apiConfig = apiConfig @@ -33,6 +45,7 @@ public actor BreezeLambdaAPI: Service { timeout: .seconds(60), logger: logger ) + let operation = try apiConfig.operation() let dynamoDBService = await BreezeDynamoDBService( config: config, httpConfig: httpConfig, @@ -40,7 +53,7 @@ public actor BreezeLambdaAPI: Service { ) let breezeLambdaService = BreezeLambdaService( dynamoDBService: dynamoDBService, - operation: try apiConfig.operation(), + operation: operation, logger: logger ) self.serviceGroup = ServiceGroup( @@ -54,6 +67,11 @@ public actor BreezeLambdaAPI: Service { } } + /// Runs the BreezeLambdaAPI service. + /// This method starts the internal ServiceGroup and begins processing requests. + /// - Throws: An error if the service fails to start or if an issue occurs during execution. + /// + /// The internal ServiceGroup will handle the lifecycle of the BreezeLambdaAPI, including starting and stopping the service gracefully. public func run() async throws { logger.info("Starting BreezeLambdaAPI...") try await serviceGroup.run() diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift index ead9910..2d10e67 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift @@ -18,15 +18,23 @@ import FoundationEssentials import Foundation #endif +/// BreezeLambdaAPIError is an enumeration that defines various errors that can occur in the Breeze Lambda API. enum BreezeLambdaAPIError: Error { + /// Indicates that an item is invalid. case invalidItem + /// Indicates that the DynamoDB table name is not found in the environment. case tableNameNotFound + /// Indicates that the key name for the DynamoDB table is not found in the environment. case keyNameNotFound + /// Indicates that the request made to the API is invalid. case invalidRequest + /// Indicates that the _HANDLER environment variable is invalid or missing. case invalidHandler + /// Indicates that the service is invalid, possibly due to misconfiguration or an unsupported operation. case invalidService } +/// Extension for BreezeLambdaAPIError to provide localized error descriptions. extension BreezeLambdaAPIError: LocalizedError { var errorDescription: String? { switch self { diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift index 41b847e..1bdc95c 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift @@ -17,6 +17,16 @@ import AWSLambdaRuntime import BreezeDynamoDBService import Logging +/// BreezeLambdaHandler implements a Lambda handler for Breeze operations. +/// It conforms to the `LambdaHandler` protocol and is generic over a type `T` that conforms to `BreezeCodable`. +/// +/// This handler supports the following operations: +/// +/// - Create: Creates a new item in the DynamoDB table. +/// - Read: Reads an item from the DynamoDB table based on the provided key. +/// - Update: Updates an existing item in the DynamoDB table. +/// - Delete: Deletes an item from the DynamoDB table based on the provided key and timestamps. +/// - List: Lists items in the DynamoDB table with optional pagination. struct BreezeLambdaHandler: LambdaHandler, Sendable { typealias Event = APIGatewayV2Request typealias Output = APIGatewayV2Response @@ -28,6 +38,12 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { self.dbManager.keyName } + /// Lambda handler for Breeze operations. + /// - Parameters: + /// - event: The event containing the API Gateway request. + /// - context: The Lambda context providing information about the invocation. + /// + /// This initializer sets up the Breeze Lambda handler with the specified DynamoDB manager and operation. func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { switch self.operation { case .create: @@ -43,6 +59,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } + /// Lambda handler for creating an item in the DynamoDB table. func createLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let item: T = try? event.bodyObject() else { let error = BreezeLambdaAPIError.invalidRequest @@ -56,6 +73,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } + /// Lambda handler for reading an item from the DynamoDB table. func readLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let key = event.pathParameters?[keyName] else { let error = BreezeLambdaAPIError.invalidRequest @@ -69,6 +87,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } + /// Lambda handler for updating an item in the DynamoDB table. func updateLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let item: T = try? event.bodyObject() else { let error = BreezeLambdaAPIError.invalidRequest @@ -88,6 +107,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { var updatedAt: String? } + /// Lambda handler for deleting an item from the DynamoDB table. func deleteLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let key = event.pathParameters?[keyName], let createdAt = event.queryStringParameters?["createdAt"], @@ -104,6 +124,7 @@ struct BreezeLambdaHandler: LambdaHandler, Sendable { } } + /// Lambda handler for listing items in the DynamoDB table. func listLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { do { let key = event.queryStringParameters?["exclusiveStartKey"] diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift index 505999b..e0615c8 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaService.swift +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -25,25 +25,42 @@ import FoundationEssentials import Foundation #endif +/// BreezeLambdaService is an actor that provides a service for handling AWS Lambda events using BreezeCodable models. +/// It conforms to the `Service` protocol and implements the `handler` method to process incoming events. +/// It manages the lifecycle of a BreezeLambdaHandler, which is responsible for handling the actual business logic. +/// It also provides a method to run the service and handle graceful shutdowns. +/// it operates on a BreezeCodable model type `T` that conforms to the BreezeCodable protocol. actor BreezeLambdaService: Service { + /// DynamoDBService is an instance of BreezeDynamoDBServing that provides access to the DynamoDB database manager. let dynamoDBService: BreezeDynamoDBServing + /// Operation is an instance of BreezeOperation that defines the operation to be performed by the BreezeLambdaHandler. let operation: BreezeOperation + /// Logger is an instance of Logger for logging messages during the service's operation. let logger: Logger + /// Initializes a new instance of `BreezeLambdaService`. + /// - Parameters: + /// - dynamoDBService: An instance of `BreezeDynamoDBServing` that provides access to the DynamoDB database manager. + /// - operation: The `BreezeOperation` that defines the operation to be performed by the BreezeLambdaHandler. + /// - logger: A `Logger` instance for logging messages during the service's operation. init(dynamoDBService: BreezeDynamoDBServing, operation: BreezeOperation, logger: Logger) { self.dynamoDBService = dynamoDBService self.operation = operation self.logger = logger } + /// BreezeLambdaHandler is an optional instance of BreezeLambdaHandler that will handle the actual business logic. var breezeApi: BreezeLambdaHandler? + /// Handler method that processes incoming AWS Lambda events. func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { guard let breezeApi else { throw BreezeLambdaAPIError.invalidHandler } return try await breezeApi.handle(event, context: context) } + /// Runs the BreezeLambdaService, initializing the BreezeLambdaHandler and starting the Lambda runtime. + /// - Throws: An error if the service fails to initialize or run. func run() async throws { let dbManager = await dynamoDBService.dbManager() let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: self.operation) @@ -64,6 +81,8 @@ actor BreezeLambdaService: Service { } } + /// 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 diff --git a/Sources/BreezeLambdaAPI/BreezeOperation.swift b/Sources/BreezeLambdaAPI/BreezeOperation.swift index 8169cfd..22aa6eb 100644 --- a/Sources/BreezeLambdaAPI/BreezeOperation.swift +++ b/Sources/BreezeLambdaAPI/BreezeOperation.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// BreezeOperation is an enumeration that defines the operations supported by Breeze Lambda API. +/// It includes operations such as create, read, update, delete, and list. public enum BreezeOperation: String, Sendable { case create case read @@ -19,6 +21,11 @@ public enum BreezeOperation: String, Sendable { case delete case list + /// Initializes a BreezeOperation from a handler string. + /// + /// - Parameter handler: A string representing the handler, typically in the format "module.operation". + /// - Returns: An optional BreezeOperation if the handler string can be parsed successfully. + /// - Note: This initializer extracts the operation from the handler string by splitting it at the last dot (.) and matching it to a BreezeOperation case. init?(handler: String) { guard let value = handler.split(separator: ".").last, let operation = BreezeOperation(rawValue: String(value)) From ea1aeb61964634e9597c86b5fe22fce39a3aec66 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 13:59:51 +0100 Subject: [PATCH 20/29] Add DocC plugin --- Makefile | 15 +++++++++++++++ Package.resolved | 20 +++++++++++++++++++- Package.swift | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e145e16..111abc1 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,21 @@ local_invoke_demo_app: test: swift test --sanitize=thread --enable-code-coverage +generate_docc: + mkdir -p docs && \ + swift package --allow-writing-to-directory docs/BreezeLambdaAPI generate-documentation \ + --target BreezeLambdaAPI \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/" \ + --output-path docs/BreezeLambdaAPI && \ + swift package --allow-writing-to-directory docs/BreezeDynamoDBService generate-documentation \ + --target BreezeDynamoDBService \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/" \ + --output-path docs/BreezeDynamoDBService + coverage: llvm-cov export $(TEST_PACKAGE) \ --instr-profile=$(SWIFT_BIN_PATH)/codecov/default.profdata \ diff --git a/Package.resolved b/Package.resolved index a14a2f7..417f81f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cfd64613639f16e5d746e97a837e99292baf72475b0d78f40b33e4990d4251d9", + "originHash" : "8e4e3e37a18dc0aa18179579b953717ed2f94090277b99a7c276733b37649186", "pins" : [ { "identity" : "async-http-client", @@ -118,6 +118,24 @@ "version" : "1.1.2" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ffccc29..2a56f41 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), .package(url: "https://github.com/soto-project/soto.git", from: "7.0.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ .executableTarget( From f9c1543664a5273dc6531d99503d3a9f6c634c48 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 14:51:09 +0100 Subject: [PATCH 21/29] Fix base urls --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 111abc1..30ed515 100644 --- a/Makefile +++ b/Makefile @@ -40,13 +40,13 @@ generate_docc: --target BreezeLambdaAPI \ --disable-indexing \ --transform-for-static-hosting \ - --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/" \ + --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/BreezeLambdaAPI/" \ --output-path docs/BreezeLambdaAPI && \ swift package --allow-writing-to-directory docs/BreezeDynamoDBService generate-documentation \ --target BreezeDynamoDBService \ --disable-indexing \ --transform-for-static-hosting \ - --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/" \ + --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/BreezeDynamoDBService/" \ --output-path docs/BreezeDynamoDBService coverage: From 07c6e38d9ab5731732bc936bd4c5c1d44773a0ef Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 15:01:09 +0100 Subject: [PATCH 22/29] Add SPI config --- .spi.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .spi.yml diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..8e272eb --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [BreezeLambdaAPI, BreezeDynamoDBService] \ No newline at end of file From 53df57fbe14c74aed4338692d055fec5f1e80855 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 15:41:10 +0100 Subject: [PATCH 23/29] Fix Readme and rename Demo App --- Package.swift | 8 +++--- README.md | 11 +++++++- .../BreezeLambdaItemAPI.swift} | 28 ++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) rename Sources/{BreezeDemoApplication/BreezeDemoApplication.swift => BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift} (51%) diff --git a/Package.swift b/Package.swift index 2a56f41..aacc750 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription #if os(macOS) -let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15), .iOS(.v13)] +let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15)] #else let platforms: [PackageDescription.SupportedPlatform]? = nil #endif @@ -21,8 +21,8 @@ let package = Package( targets: ["BreezeLambdaAPI"] ), .executable( - name: "BreezeDemoApplication", - targets: ["BreezeDemoApplication"] + name: "BreezeLambdaItemAPI", + targets: ["BreezeLambdaItemAPI"] ) ], dependencies: [ @@ -35,7 +35,7 @@ let package = Package( ], targets: [ .executableTarget( - name: "BreezeDemoApplication", + name: "BreezeLambdaItemAPI", dependencies: [ "BreezeLambdaAPI" ] diff --git a/README.md b/README.md index 26e860d..d03e893 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,16 @@ extension Item: BreezeCodable { } Add the implementation of the Lambda to the file `swift.main` ```swift -BreezeLambdaAPI.main() +@main +struct BreezeLambdaItemAPI { + static func main() async throws { + do { + try await BreezeLambdaAPI().run() + } catch { + print(error.localizedDescription) + } + } +} ``` ## Documentation diff --git a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift b/Sources/BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift similarity index 51% rename from Sources/BreezeDemoApplication/BreezeDemoApplication.swift rename to Sources/BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift index 1964ffc..4fcf9e7 100644 --- a/Sources/BreezeDemoApplication/BreezeDemoApplication.swift +++ b/Sources/BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift @@ -15,6 +15,11 @@ import BreezeLambdaAPI import BreezeDynamoDBService +/// The BreezeLambdaItemAPI is an example of a Breeze Lambda API that interacts with DynamoDB to manage items. +/// Use this example to understand how to create a Breeze Lambda API that can list, create, update, and delete items in a DynamoDB table. + +/// The Item struct represents an item in the DynamoDB table. +/// It conforms to Codable for easy encoding and decoding to/from JSON. struct Item: Codable { public var key: String public let name: String @@ -31,27 +36,48 @@ struct Item: Codable { } } +/// BreezeCodable is a protocol that allows the Item struct to be used with Breeze Lambda API. extension Item: BreezeCodable { } +/// APIConfiguration is a struct that conforms to APIConfiguring. +/// It provides the configuration for the Breeze Lambda API, including the DynamoDB table name, key name, and endpoint. +/// It also specifies the operation to be performed, which in this case is listing items. struct APIConfiguration: APIConfiguring { let dbTimeout: Int64 = 30 func operation() throws -> BreezeOperation { .list } + /// Get the configuration for the DynamoDB service. + /// It specifies the region, table name, key name, and endpoint. + /// In this example, it uses a local Localstack endpoint for testing purposes. + /// You can change the region, table name, key name, and endpoint as needed for your application. + /// Remove the endpoint for production use. func getConfig() throws -> BreezeDynamoDBConfig { BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "itemKey", endpoint: "http://127.0.0.1:4566") } } @main -struct BreezeDemoApplication { +struct BreezeLambdaItemAPI { static func main() async throws { +#if DEBUG do { let lambdaAPIService = try await BreezeLambdaAPI(apiConfig: APIConfiguration()) try await lambdaAPIService.run() } catch { print(error.localizedDescription) } +#else + // In production, you can run the BreezeLambdaAPI without the API configuration. + // This will use the default configuration for the BreezeDynamoDBService. + // Make sure to set the environment variables for the DynamoDB service: + // DYNAMODB_TABLE_NAME, DYNAMODB_KEY_NAME, and AWS_REGION. + do { + try await BreezeLambdaAPI().run() + } catch { + print(error.localizedDescription) + } +#endif } } From 57bc777a8ba92bce9645e2ff635ecc65f124b1f1 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 15:49:47 +0100 Subject: [PATCH 24/29] Add docc article --- Images/Icon.png | Bin 0 -> 122072 bytes .../BreezeLambdaDynamoDBAPI.md | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 Images/Icon.png create mode 100644 Sources/BreezeLambdaDynamoDBAPI.docc/BreezeLambdaDynamoDBAPI.md diff --git a/Images/Icon.png b/Images/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c74248ef597b275993bd1f99d1f59f694caa6109 GIT binary patch literal 122072 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 + - +} From a1c400403a03e072c20d3a6ab5d655c40cf76b65 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 15:58:21 +0100 Subject: [PATCH 25/29] Comment package --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index aacc750..834bacc 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( ) ], dependencies: [ + // TODO: change to upstream once the upstream is tagged .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/swift-service-lifecycle.git", from: "2.0.0"), From 36188dd6218f191e6540c12f1a58b4beb2a707fa Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 16:34:30 +0100 Subject: [PATCH 26/29] Fix Tests --- Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift index 0d5ffa2..7a911d5 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift @@ -13,7 +13,6 @@ // limitations under the License. import BreezeDynamoDBService -@testable import BreezeLambdaAPI import SotoDynamoDB actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { From 8aa6c645a991f658efec193f412fcf420adf7e53 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 16:47:05 +0100 Subject: [PATCH 27/29] Update to swift 6.1.2 --- .github/workflows/meterian.yml | 2 +- .github/workflows/swift-test.yml | 2 +- .../BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift | 1 - docker/Dockerfile | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml index e3ef994..1e95a3b 100644 --- a/.github/workflows/meterian.yml +++ b/.github/workflows/meterian.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: swift-actions/setup-swift@v2 with: - swift-version: '6.1.0' + swift-version: '6.1.2' - name: Get swift version run: swift --version - name: Checkout diff --git a/.github/workflows/swift-test.yml b/.github/workflows/swift-test.yml index 71cb777..9232e09 100644 --- a/.github/workflows/swift-test.yml +++ b/.github/workflows/swift-test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: image: - - swift:6.1.0 + - swift:6.1.2 services: localstack: image: localstack/localstack diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift index 7a911d5..aaa9ac7 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift @@ -22,7 +22,6 @@ actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { case invalidRequest case invalidItem } - private var response: (any BreezeCodable)? private var keyedResponse: (any BreezeCodable)? diff --git a/docker/Dockerfile b/docker/Dockerfile index ba01dcd..c1096cc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.1.0-amazonlinux2 as builder +FROM swift:6.1.2-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make From bb1f3badb4879421ba187544d4f6db32bbfecd74 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 17:11:17 +0100 Subject: [PATCH 28/29] 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 f64f310e88945efdaafd2fe6b2602e52cd6df499 Mon Sep 17 00:00:00 2001 From: Andrea Scuderi Date: Sat, 12 Jul 2025 17:13:09 +0100 Subject: [PATCH 29/29] Remove Package.resolved --- .gitignore | 2 +- Package.resolved | 249 ----------------------------------------------- 2 files changed, 1 insertion(+), 250 deletions(-) delete mode 100644 Package.resolved diff --git a/.gitignore b/.gitignore index c9e8bdf..3e6760a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -# Package.resolved +Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 417f81f..0000000 --- a/Package.resolved +++ /dev/null @@ -1,249 +0,0 @@ -{ - "originHash" : "8e4e3e37a18dc0aa18179579b953717ed2f94090277b99a7c276733b37649186", - "pins" : [ - { - "identity" : "async-http-client", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/async-http-client.git", - "state" : { - "revision" : "2119f0d9cc1b334e25447fe43d3693c0e60e6234", - "version" : "1.24.0" - } - }, - { - "identity" : "jmespath.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/adam-fowler/jmespath.swift.git", - "state" : { - "revision" : "3877a5060e85ae33e3b9fe51ab581784f65ec80e", - "version" : "1.0.3" - } - }, - { - "identity" : "soto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/soto-project/soto.git", - "state" : { - "revision" : "c9afb020142858c23439ef247a7df330edc8f589", - "version" : "7.1.0" - } - }, - { - "identity" : "soto-core", - "kind" : "remoteSourceControl", - "location" : "https://github.com/soto-project/soto-core.git", - "state" : { - "revision" : "29848123812bd2624d2e2dc93a9b9009c2abe812", - "version" : "7.1.0" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms", - "state" : { - "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "7faebca1ea4f9aaf0cda1cef7c43aecd2311ddf6", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-aws-lambda-events", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-aws-lambda-events.git", - "state" : { - "revision" : "cfd688e499894ed0ba527f1decf4dfc17ec06492", - "version" : "0.5.0" - } - }, - { - "identity" : "swift-aws-lambda-runtime", - "kind" : "remoteSourceControl", - "location" : "https://github.com/andrea-scuderi/swift-aws-lambda-runtime.git", - "state" : { - "branch" : "main", - "revision" : "5924fb6e75b76d45bf427e02c0017d733b235903" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "ff0f781cf7c6a22d52957e50b104f5768b50c779", - "version" : "3.10.0" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "6483d340853a944c96dbcc28b27dd10b6c581703", - "version" : "1.1.2" - } - }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types.git", - "state" : { - "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", - "version" : "1.6.2" - } - }, - { - "identity" : "swift-metrics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-metrics.git", - "state" : { - "revision" : "e0165b53d49b413dd987526b641e05e246782685", - "version" : "2.5.0" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "0f54d58bb5db9e064f332e8524150de379d1e51c", - "version" : "2.82.1" - } - }, - { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "d1ead62745cc3269e482f1c51f27608057174379", - "version" : "1.24.0" - } - }, - { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "b5f7062b60e4add1e8c343ba4eb8da2e324b3a94", - "version" : "1.34.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "7b84abbdcef69cc3be6573ac12440220789dcd69", - "version" : "2.27.2" - } - }, - { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "38ac8221dd20674682148d6451367f89c2652980", - "version" : "1.21.0" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", - "version" : "1.0.2" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "0c62c5b4601d6c125050b5c3a97f20cce881d32b", - "version" : "1.1.0" - } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", - "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", - "version" : "1.4.0" - } - } - ], - "version" : 3 -}