diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml index f352bc2..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: "5.7.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..71cb777 100644 --- a/.github/workflows/swift-test.yml +++ b/.github/workflows/swift-test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: image: - - swift:5.10.1 + - swift:6.1.0 services: localstack: image: localstack/localstack 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..c9e4002 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,231 @@ +{ + "originHash" : "672f8141a46f0621c29e23ab11f1efa73c11bdecea3447837c9f1766e045082a", + "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/swift-server/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-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 +} diff --git a/Package.swift b/Package.swift index 2173617..eec0984 100644 --- a/Package.swift +++ b/Package.swift @@ -1,37 +1,63 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 6.0 import PackageDescription +#if os(macOS) +let platforms: [PackageDescription.SupportedPlatform]? = [.macOS(.v15), .iOS(.v13)] +#else +let platforms: [PackageDescription.SupportedPlatform]? = nil +#endif + let package = Package( name: "BreezeLambdaDynamoDBAPI", - platforms: [ - .macOS(.v13), - ], + platforms: platforms, 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/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/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"), + .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( + 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,20 +65,36 @@ 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" ] ), .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" ], resources: [.copy("Fixtures")] ), .testTarget( name: "BreezeDynamoDBServiceTests", - dependencies: ["BreezeDynamoDBService"] + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .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 new file mode 100644 index 0000000..fa520cd --- /dev/null +++ b/Sources/BreezeDemoApplication/BreezeDemoApplication.swift @@ -0,0 +1,57 @@ +// 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 BreezeLambdaAPI +import BreezeDynamoDBService + +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 { + 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 29f8f46..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. @@ -12,9 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif -public protocol BreezeCodable: Codable { +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/Tests/BreezeLambdaAPITests/Utils.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift similarity index 53% rename from Tests/BreezeLambdaAPITests/Utils.swift rename to Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift index d98e67c..ea4b05c 100644 --- a/Tests/BreezeLambdaAPITests/Utils.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. @@ -12,9 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation +import SotoCore -func setEnvironmentVar(name: String, value: String, overwrite: Bool) { - setenv(name, value, overwrite ? 1 : 0) +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 + } + + public let region: Region + public let tableName: String + public let keyName: String + public let endpoint: String? } - diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift new file mode 100644 index 0000000..fd15eda --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift @@ -0,0 +1,123 @@ +// 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 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 90% rename from Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift rename to Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift index 9713ca4..b4208f8 100644 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBServing.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. @@ -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..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. @@ -12,112 +12,76 @@ // See the License for the specific language governing permissions and // limitations under the License. -import struct Foundation.Date -import NIO import SotoDynamoDB +import ServiceLifecycle +import BreezeHTTPClientService +import Logging -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 - } +public protocol BreezeDynamoDBServing: Actor, Service { + func dbManager() async -> BreezeDynamoDBManaging } -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 +public actor BreezeDynamoDBService: BreezeDynamoDBServing { + + 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 _ = 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 dbManager = DBManagingType.init( + db: db, + tableName: config.tableName, + keyName: config.keyName ) - let data = try await db.getItem(input, type: T.self) - guard let item = data.item else { - throw ServiceError.notFound - } - return item + _dbManager = dbManager + return dbManager } - - private struct AdditionalAttributes: Encodable { - let oldUpdatedAt: String + + public init( + config: BreezeDynamoDBConfig, + serviceConfig: BreezeClientServiceConfig, + DBManagingType: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self + ) { + self.config = config + self.serviceConfig = serviceConfig + self.DBManagingType = DBManagingType } - 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) + private var awsClient: AWSClient? + + private var logger: Logger { + serviceConfig.logger } - - func deleteItem(item: T) async throws { - guard let updatedAt = item.updatedAt, - let createdAt = item.createdAt else { - throw ServiceError.missingParameters - } + + 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)") - 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 + try await gracefulShutdown() + + logger.info("Stopping DynamoDBService...") + try await awsClient?.shutdown() + self.awsClient = nil + logger.info("DynamoDBService is stopped.") } - - 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) - } + + deinit { + try? awsClient?.syncShutdown() } } + 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 a1cfbd5..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. @@ -12,14 +12,17 @@ // 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 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/Tests/BreezeDynamoDBServiceTests/Utils.swift b/Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift similarity index 59% rename from Tests/BreezeDynamoDBServiceTests/Utils.swift rename to Sources/BreezeHTTPClientService/BreezeClientServiceConfig.swift index 822b27c..25579d4 100644 --- a/Tests/BreezeDynamoDBServiceTests/Utils.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. @@ -12,15 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation +import Logging -func getEnvironmentVar(name: String) -> String? { - guard let envValue = getenv(name) else { - return nil +public struct BreezeClientServiceConfig: Sendable { + + public let httpClientService: BreezeHTTPClientServing + public let logger: Logger + + public init( + httpClientService: BreezeHTTPClientServing, + logger: Logger + ) { + self.httpClientService = httpClientService + self.logger = logger } - return String(cString: envValue) -} - -func setEnvironmentVar(name: String, value: String, overwrite: Bool) { - setenv(name, value, overwrite ? 1 : 0) } diff --git a/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift new file mode 100644 index 0000000..1a31b26 --- /dev/null +++ b/Sources/BreezeHTTPClientService/BreezeHTTPClientService.swift @@ -0,0 +1,57 @@ +// 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/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 8da9234..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. @@ -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/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/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index a350d20..7364ef9 100644 --- a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.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. @@ -12,113 +12,74 @@ // 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 +import ServiceLifecycle +import BreezeDynamoDBService +import BreezeHTTPClientService +import AWSLambdaRuntime -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 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 { + do { + self.apiConfig = apiConfig + self.timeout = .seconds(apiConfig.dbTimeout) + self.httpClientService = BreezeHTTPClientService( + timeout: timeout, + logger: logger + ) + let config = try apiConfig.getConfig() + let serviceConfig = BreezeClientServiceConfig( + httpClientService: httpClientService, + logger: logger + ) + self.dynamoDBService = BreezeDynamoDBService(config: config, serviceConfig: serviceConfig) + self.breezeLambdaService = BreezeLambdaService( + dynamoDBService: dynamoDBService, + operation: try apiConfig.operation(), + 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)") + throw error } } - - 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) + + public func run() async throws { + logger.info("Starting BreezeLambdaAPIService...") + try await serviceGroup.run() + 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 2d476bb..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. @@ -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 @@ -20,4 +24,24 @@ enum BreezeLambdaAPIError: Error { case keyNameNotFound case invalidRequest 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/BreezeLambdaHandler.swift b/Sources/BreezeLambdaAPI/BreezeLambdaHandler.swift index 82996fb..41b847e 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. @@ -17,18 +17,18 @@ import AWSLambdaRuntime import BreezeDynamoDBService import Logging -struct BreezeLambdaHandler { +struct BreezeLambdaHandler: LambdaHandler, Sendable { typealias Event = APIGatewayV2Request typealias Output = APIGatewayV2Response - let service: BreezeDynamoDBServing + let dbManager: BreezeDynamoDBManaging let operation: BreezeOperation var keyName: String { - self.service.keyName + self.dbManager.keyName } - - func handle(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { switch self.operation { case .create: return await self.createLambdaHandler(context: context, event: event) @@ -43,39 +43,39 @@ struct BreezeLambdaHandler { } } - 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) } 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) } } - 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) } 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) } } - 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) } 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) @@ -88,7 +88,7 @@ struct BreezeLambdaHandler { 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 { @@ -97,18 +97,18 @@ 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) } } - 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") - 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 new file mode 100644 index 0000000..f3f71a6 --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -0,0 +1,57 @@ +// 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 BreezeDynamoDBService +import AWSLambdaRuntime +import AWSLambdaEvents +import Logging + +actor BreezeLambdaService: Service { + + let dynamoDBService: BreezeDynamoDBServing + let operation: BreezeOperation + let logger: Logger + + init(dynamoDBService: BreezeDynamoDBServing, operation: BreezeOperation, logger: Logger) { + self.dynamoDBService = dynamoDBService + self.operation = operation + self.logger = logger + } + + var breezeApi: BreezeLambdaHandler? + + 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 { + 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 + } + } +} 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 new file mode 100644 index 0000000..0d2d775 --- /dev/null +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift @@ -0,0 +1,258 @@ +// 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 SotoCore +import SotoDynamoDB +import Testing +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +@testable import BreezeDynamoDBService + +struct Product: BreezeCodable { + var key: String + var name: String + var description: String + var createdAt: String? + var updatedAt: String? +} + +@Suite +struct BreezeDynamoDBManagerTests { + + let keyName = "key" + + 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 🪄!") + + func givenTable(tableName: String) async throws -> BreezeDynamoDBManager { + try await LocalStackDynamoDB.createTable(name: tableName, keyName: keyName) + let db = LocalStackDynamoDB.dynamoDB + return BreezeDynamoDBManager(db: db, tableName: tableName, keyName: keyName) + } + + func removeTable(tableName: String) async throws { + try await LocalStackDynamoDB.deleteTable(name: tableName) + } + + @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) + #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) + #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) + Issue.record("It should throw conditionalCheckFailedException") + } catch { + 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") + #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) + #expect(value.key == "2023") + do { + let _: Product = try await sut.readItem(key: "2022") + Issue.record("It should throw when Item is missing") + } catch { + 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) + #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) + #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) + Issue.record("It should throw conditionalCheckFailedException") + } catch { + try #require(error != nil) + } + + do { + let _: Product = try await sut.updateItem(item: product2022) + Issue.record("It should throw conditionalCheckFailedException") + } catch { + 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) + #expect(value.key == "2023") + try await sut.deleteItem(item: value) + let readedItem: Product? = try? await sut.readItem(key: "2023") + #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) + Issue.record("It should throw ServiceError.missingParameters") + } catch { + 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) + #expect(value.key == "2023") + value.updatedAt = nil + do { + try await sut.deleteItem(item: value) + Issue.record("It should throw ServiceError.missingParameters") + } catch { + 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) + #expect(value.key == "2023") + value.createdAt = nil + do { + try await sut.deleteItem(item: value) + Issue.record("It should throw ServiceError.missingParameters") + } catch { + 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) + #expect(value.key == "2023") + value.updatedAt = Date().iso8601 + do { + try await sut.deleteItem(item: value) + Issue.record("It should throw ServiceError.missingParameters") + } catch { + 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) + #expect(value.key == "2023") + value.createdAt = Date().iso8601 + do { + try await sut.deleteItem(item: value) + Issue.record("It should throw ServiceError.missingParameters") + } catch { + 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) + #expect(list.items.count == 2) + let keys = Set(list.items.map { $0.key }) + #expect(keys.contains(value1.key)) + #expect(keys.contains(value2.key)) + try await removeTable(tableName: uuid) + } +} diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift deleted file mode 100644 index ca0f32c..0000000 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift +++ /dev/null @@ -1,206 +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 SotoCore -import SotoDynamoDB -import XCTest -@testable import BreezeDynamoDBService - -struct Product: BreezeCodable { - var key: String - var name: String - var description: String - var createdAt: String? - var updatedAt: String? -} - -final class BreezeDynamoDBServiceTests: XCTestCase { - - let tableName = "Breeze" - let keyName = "key" - var sut: BreezeDynamoDBService! - - 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() - try await LocalStackDynamoDB.createTable(name: tableName, keyName: keyName) - let db = LocalStackDynamoDB.dynamoDB - sut = BreezeDynamoDBService(db: db, tableName: tableName, keyName: keyName) - } - - override func tearDown() async throws { - sut = nil - try await LocalStackDynamoDB.deleteTable(name: tableName) - try await super.tearDown() - } - - func test_createItem() async throws { - 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) - } - - func test_createItemDuplicate_shouldThrowConditionalCheckFailedException() async throws { - 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) - do { - _ = try await sut.createItem(item: product2023) - XCTFail("It should throw conditionalCheckFailedException") - } catch { - XCTAssertNotNil(error) - } - } - - func test_readItem() async throws { - 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) - } - - func test_readItem_whenItemIsMissing() async throws { - let value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") - do { - let _: Product = try await sut.readItem(key: "2022") - XCTFail("It should throw when Item is missing") - } catch { - XCTAssertNotNil(error) - } - } - - func test_updateItem() async throws { - 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) - } - - func test_updateItem_whenItemHasChanged_shouldThrowConditionalCheckFailedException() async throws { - 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) - do { - let _: Product = try await sut.updateItem(item: product2023) - XCTFail("It should throw conditionalCheckFailedException") - } catch { - XCTAssertNotNil(error) - } - - do { - let _: Product = try await sut.updateItem(item: product2022) - XCTFail("It should throw conditionalCheckFailedException") - } catch { - XCTAssertNotNil(error) - } - } - - func test_deleteItem() async throws { - let value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") - try await sut.deleteItem(item: value) - let readedItem: Product? = try? await sut.readItem(key: "2023") - XCTAssertNil(readedItem) - } - - func test_deleteItem_whenItemIsMissing_thenShouldThrow() async throws { - do { - try await sut.deleteItem(item: product2022) - XCTFail("It should throw ServiceError.missingParameters") - } catch { - XCTAssertNotNil(error) - } - } - - func test_deleteItem_whenMissingUpdatedAt_thenShouldThrow() async throws { - var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") - value.updatedAt = nil - do { - try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") - } catch { - XCTAssertNotNil(error) - } - } - - func test_deleteItem_whenMissingCreatedAt_thenShouldThrow() async throws { - var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") - value.createdAt = nil - do { - try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") - } catch { - XCTAssertNotNil(error) - } - } - - func test_deleteItem_whenOutdatedUpdatedAt_thenShouldThrow() async throws { - var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") - value.updatedAt = Date().iso8601 - do { - try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") - } catch { - XCTAssertNotNil(error) - } - } - - func test_deleteItem_whenOutdatedCreatedAt_thenShouldThrow() async throws { - var value = try await sut.createItem(item: product2023) - XCTAssertEqual(value.key, "2023") - value.createdAt = Date().iso8601 - do { - try await sut.deleteItem(item: value) - XCTFail("It should throw ServiceError.missingParameters") - } catch { - XCTAssertNotNil(error) - } - } - - func test_listItem() async throws { - 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) - let keys = Set(list.items.map { $0.key }) - XCTAssertTrue(keys.contains(value1.key)) - XCTAssertTrue(keys.contains(value2.key)) - } -} diff --git a/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift b/Tests/BreezeDynamoDBServiceTests/LocalStackDynamoDB.swift index dde98a2..376898b 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. @@ -13,20 +13,21 @@ // limitations under the License. import SotoDynamoDB +import AWSLambdaRuntime import Logging enum LocalStackDynamoDB { - static var endpoint: String = { - if let localstack = getEnvironmentVar(name: "LOCALSTACK_ENDPOINT"), + static let endpoint: String = { + if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), !localstack.isEmpty { return localstack } return "http://localhost:4566" }() - public static var logger: Logger = { - if let loggingLevel = getEnvironmentVar(name: "AWS_LOG_LEVEL") { + public static let logger: Logger = { + if let loggingLevel = Lambda.env("AWS_LOG_LEVEL") { if let logLevel = Logger.Level(rawValue: loggingLevel.lowercased()) { var logger = Logger(label: "breeze") logger.logLevel = logLevel @@ -36,13 +37,12 @@ enum LocalStackDynamoDB { return AWSClient.loggingDisabled }() - static var client = AWSClient( + static let client = AWSClient( credentialProvider: .static(accessKeyId: "breeze", secretAccessKey: "magic"), - middlewares: [AWSLoggingMiddleware()], - httpClientProvider: .createNew + middleware: AWSLoggingMiddleware() ) - static var dynamoDB = DynamoDB( + static let dynamoDB = DynamoDB( client: client, region: .useast1, endpoint: endpoint @@ -67,4 +67,3 @@ enum LocalStackDynamoDB { _ = try await Self.dynamoDB.deleteTable(input, logger: Self.logger) } } - diff --git a/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift new file mode 100644 index 0000000..c6621cd --- /dev/null +++ b/Tests/BreezeHTTPClientServiceTests/BreezeHTTPClientServiceTests.swift @@ -0,0 +1,48 @@ +// 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/APIGatewayV2Response.swift b/Tests/BreezeLambdaAPITests/APIGatewayV2Response.swift index 157dcf4..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. @@ -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/BreezeDynamoDBManagerMock.swift similarity index 70% rename from Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift rename to Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift index c4758f6..33297d5 100644 --- a/Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.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. @@ -16,25 +16,30 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB -struct BreezeDynamoDBServiceMock: BreezeDynamoDBServing { - var keyName: String +actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { + let keyName: String - static var response: (any BreezeCodable)? - 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: BreezeDynamoDBServing { } 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: BreezeDynamoDBServing { } 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: BreezeDynamoDBServing { return } - static var limit: Int? - 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/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/BreezeLambdaAPITests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift deleted file mode 100644 index 3e32005..0000000 --- a/Tests/BreezeLambdaAPITests/BreezeLambdaAPITests.swift +++ /dev/null @@ -1,307 +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 AWSLambdaEvents -import AWSLambdaRuntime -import AWSLambdaTesting -import BreezeDynamoDBService -@testable import BreezeLambdaAPI -import XCTest - -final class BreezeLambdaAPITests: XCTestCase { - - let decoder = JSONDecoder() - - override func setUpWithError() throws { - try super.setUpWithError() - 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) - LambdaInitializationContext.DynamoDB.Service = BreezeDynamoDBServiceMock.self - LambdaInitializationContext.DynamoDB.dbTimeout = 1 - } - - override func tearDownWithError() throws { - unsetenv("LOCAL_LAMBDA_SERVER_ENABLED") - unsetenv("AWS_REGION") - unsetenv("DYNAMO_DB_TABLE_NAME") - unsetenv("DYNAMO_DB_KEY") - unsetenv("_HANDLER") - LambdaInitializationContext.DynamoDB.Service = BreezeDynamoDBService.self - LambdaInitializationContext.DynamoDB.dbTimeout = 30 - BreezeDynamoDBServiceMock.reset() - try super.tearDownWithError() - } - - func test_initWhenMissing_AWS_REGION_thenDefaultRegion() async throws { - 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(BreezeLambdaAPI.self, with: request) - } - - func test_initWhenMissing__HANDLER_thenThrowError() async throws { - 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(BreezeLambdaAPI.self, with: request) - XCTFail("It should throw an Error when _HANDLER is missing") - } catch BreezeLambdaAPIError.invalidHandler { - XCTAssert(true) - } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.invalidHandler") - } - } - - func test_initWhenInvalid__HANDLER_thenThrowError() async throws { - 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(BreezeLambdaAPI.self, with: request) - XCTFail("It should throw an Error when _HANDLER is invalid") - } catch BreezeLambdaAPIError.invalidHandler { - XCTAssert(true) - } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.invalidHandler") - } - } - - func test_initWhenMissing_DYNAMO_DB_TABLE_NAME_thenThrowError() async throws { - 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(BreezeLambdaAPI.self, with: request) - XCTFail("It should throw an Error when DYNAMO_DB_TABLE_NAME is missing") - } catch BreezeLambdaAPIError.tableNameNotFound { - XCTAssert(true) - } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.tableNameNotFound") - } - } - - func test_initWhenMissing_DYNAMO_DB_KEY_thenThrowError() async throws { - 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(BreezeLambdaAPI.self, with: request) - XCTFail("It should throw an Error when DYNAMO_DB_KEY is missing") - } catch BreezeLambdaAPIError.keyNameNotFound { - XCTAssert(true) - } catch { - XCTFail("Is should throw an BreezeLambdaAPIError.keyNameNotFound") - } - } - - func test_create() async throws { - 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(BreezeLambdaAPI.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 🪄!") - } - - func test_create_whenInvalidItem_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_create_whenMissingItem_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_read() async throws { - 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(BreezeLambdaAPI.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 🪄!") - } - - func test_read_whenInvalidRequest_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_read_whenMissingItem_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_update() async throws { - 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(BreezeLambdaAPI.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 🪄!") - } - - func test_update_whenInvalidRequest_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_update_whenMissingItem_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_delete() async throws { - 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(BreezeLambdaAPI.self, with: request) - let response: BreezeEmptyResponse = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .ok) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertNotNil(response) - } - - func test_delete_whenRequestIsOutaded() async throws { - 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(BreezeLambdaAPI.self, with: request) - let response: BreezeEmptyResponse = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .notFound) - XCTAssertEqual(apiResponse.headers, [ "Content-Type": "application/json" ]) - XCTAssertNotNil(response) - } - - func test_delete_whenInvalidRequest_thenError() async throws { - 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(BreezeLambdaAPI.self, with: request) - let response: APIGatewayV2Response.BodyError = try apiResponse.decodeBody() - XCTAssertEqual(apiResponse.statusCode, .forbidden) - XCTAssertEqual(response.error, "invalidRequest") - } - - func test_delete_whenMissingItem_thenError() async throws { - 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(BreezeLambdaAPI.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") - } - - func test_list() async throws { - 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(BreezeLambdaAPI.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 🪄!") - } - - func test_list_whenError() async throws { - 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(BreezeLambdaAPI.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") - } -} diff --git a/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift new file mode 100644 index 0000000..e1eda57 --- /dev/null +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -0,0 +1,349 @@ +// Copyright 2024 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import AWSLambdaEvents +@testable import AWSLambdaRuntime +import ServiceLifecycle +import ServiceLifecycleTestKit +import BreezeDynamoDBService +import BreezeHTTPClientService +@testable import BreezeLambdaAPI +import Testing +import Logging +import AsyncHTTPClient +import NIOCore +import Foundation + + +@Suite +struct BreezeLambdaHandlerTests { + + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + let logger = Logger(label: "BreezeLambdaAPITests") + let config = BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "sku") + + @Test + func test_create() 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) + 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" ]) + #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 { + 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( + 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 { + 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( + 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 { + 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( + 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" ]) + #expect(response.key == "2023") + #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(response.description == "BreezeLambaAPI is magic 🪄!") + } + + @Test + func test_read_whenInvalidRequest_thenError() async throws { + 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( + 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 { + 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( + 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 { + 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( + 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" ]) + #expect(response.key == "2023") + #expect(response.name == "Swift Serverless API with async/await! 🚀🥳") + #expect(response.description == "BreezeLambaAPI is magic 🪄!") + } + + @Test + func test_update_whenInvalidRequest_thenError() async throws { + 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( + 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 { + 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( + 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 { + 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( + 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 { + 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( + 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 { + 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( + 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 { + 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( + 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 { + 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( + 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) +// #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 🪄!") + } + + @Test + func test_list_whenError() async throws { + 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( + 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" ]) + #expect(response.error == "invalidItem") + } +} + +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..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. @@ -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..0719753 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 8ec17f5..a6da1cf 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. @@ -13,43 +13,64 @@ // limitations under the License. import AWSLambdaEvents -import AWSLambdaRuntime -@testable import AWSLambdaRuntimeCore -import AWSLambdaTesting +@testable import AWSLambdaRuntime +import BreezeDynamoDBService +import BreezeHTTPClientService +@testable import BreezeLambdaAPI import Logging import NIO +import ServiceLifecycle +import ServiceLifecycleTestKit +import Foundation +import Logging +import Testing +import SotoDynamoDB +import AsyncHTTPClient -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() +extension AWSLambdaRuntime.Lambda { + + static func test( + _ handlerType: BreezeLambdaHandler.Type, + config: BreezeDynamoDBConfig, + operation: BreezeOperation, + response: (any BreezeCodable)?, + keyedResponse: (any BreezeCodable)?, + with event: BreezeLambdaHandler.Event) async throws -> BreezeLambdaHandler.Output { + + let logger = Logger(label: "evaluateHandler") + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + 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 initContext = LambdaInitializationContext.__forTestsOnly( - logger: logger, - eventLoop: eventLoop + 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: config.requestID, - traceID: config.traceID, - invokedFunctionARN: config.invokedFunctionARN, - timeout: config.timeout, - logger: logger, - eventLoop: eventLoop + requestID: UUID().uuidString, + traceID: UUID().uuidString, + invokedFunctionARN: "arn:", + timeout: .milliseconds(6000), + logger: logger ) - let handler = try await Handler(context: initContext) - defer { - let eventLoop = initContext.eventLoop.next() - try? initContext.terminator.terminate(eventLoop: eventLoop).wait() - } - return try await handler.handle(event, context: context) + try await handler.handle(event, responseWriter: writer, context: context) + let result = await writer.output ?? ByteBuffer() + try await awsClient.shutdown() + return try decoder.decode(BreezeLambdaHandler.Output.self, from: result) } } diff --git a/Tests/BreezeLambdaAPITests/Product.swift b/Tests/BreezeLambdaAPITests/Product.swift index b45e64c..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. @@ -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/docker/Dockerfile b/docker/Dockerfile index 0b0e9cb..ba01dcd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:5.7.3-amazonlinux2 as builder +FROM swift:6.1.0-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make