diff --git a/.github/workflows/meterian.yml b/.github/workflows/meterian.yml.disabled similarity index 94% rename from .github/workflows/meterian.yml rename to .github/workflows/meterian.yml.disabled index f352bc2..1e95a3b 100644 --- a/.github/workflows/meterian.yml +++ b/.github/workflows/meterian.yml.disabled @@ -11,7 +11,7 @@ jobs: steps: - uses: swift-actions/setup-swift@v2 with: - swift-version: "5.7.3" + swift-version: '6.1.2' - name: Get swift version run: swift --version - name: Checkout diff --git a/.github/workflows/swift-test.yml b/.github/workflows/swift-test.yml index 06a1fb4..9232e09 100644 --- a/.github/workflows/swift-test.yml +++ b/.github/workflows/swift-test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: image: - - swift:5.10.1 + - swift:6.1.2 services: localstack: image: localstack/localstack diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..8e272eb --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [BreezeLambdaAPI, BreezeDynamoDBService] \ No newline at end of file diff --git a/Images/Icon.png b/Images/Icon.png new file mode 100644 index 0000000..c74248e Binary files /dev/null and b/Images/Icon.png differ diff --git a/Makefile b/Makefile index 65c6875..30ed515 100644 --- a/Makefile +++ b/Makefile @@ -20,9 +20,35 @@ composer_down: localstack: docker run -it --rm -p "4566:4566" localstack/localstack +local_setup_dynamo_db: + aws --endpoint-url=http://localhost:4566 dynamodb create-table \ + --table-name Breeze \ + --attribute-definitions AttributeName=itemKey,AttributeType=S \ + --key-schema AttributeName=itemKey,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST + --region us-east-1 + +local_invoke_demo_app: + curl -X POST 127.0.0.1:7000/invoke -H "Content-Type: application/json" -d @Tests/BreezeLambdaAPITests/Fixtures/post_products_api_gtw.json + test: swift test --sanitize=thread --enable-code-coverage +generate_docc: + mkdir -p docs && \ + swift package --allow-writing-to-directory docs/BreezeLambdaAPI generate-documentation \ + --target BreezeLambdaAPI \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/BreezeLambdaAPI/" \ + --output-path docs/BreezeLambdaAPI && \ + swift package --allow-writing-to-directory docs/BreezeDynamoDBService generate-documentation \ + --target BreezeDynamoDBService \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path "https://swift-serverless.github.io/BreezeLambdaDynamoDBAPI/BreezeDynamoDBService/" \ + --output-path docs/BreezeDynamoDBService + coverage: llvm-cov export $(TEST_PACKAGE) \ --instr-profile=$(SWIFT_BIN_PATH)/codecov/default.profdata \ diff --git a/Package.swift b/Package.swift index 2173617..834bacc 100644 --- a/Package.swift +++ b/Package.swift @@ -1,13 +1,16 @@ -// 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)] +#else +let platforms: [PackageDescription.SupportedPlatform]? = nil +#endif + let package = Package( name: "BreezeLambdaDynamoDBAPI", - platforms: [ - .macOS(.v13), - ], + platforms: platforms, products: [ .library( name: "BreezeDynamoDBService", @@ -16,22 +19,34 @@ let package = Package( .library( name: "BreezeLambdaAPI", targets: ["BreezeLambdaAPI"] + ), + .executable( + name: "BreezeLambdaItemAPI", + targets: ["BreezeLambdaItemAPI"] ) ], 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"), + // TODO: change to upstream once the upstream is tagged + .package(url: "https://github.com/andrea-scuderi/swift-aws-lambda-runtime.git", branch: "main"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.5.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), + .package(url: "https://github.com/soto-project/soto.git", from: "7.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ + .executableTarget( + name: "BreezeLambdaItemAPI", + dependencies: [ + "BreezeLambdaAPI" + ] + ), .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"), ] ), .target( @@ -39,20 +54,28 @@ 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" + ] ) ] ) diff --git a/README.md b/README.md index 26e860d..d03e893 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,16 @@ extension Item: BreezeCodable { } Add the implementation of the Lambda to the file `swift.main` ```swift -BreezeLambdaAPI.main() +@main +struct BreezeLambdaItemAPI { + static func main() async throws { + do { + try await BreezeLambdaAPI().run() + } catch { + print(error.localizedDescription) + } + } +} ``` ## Documentation diff --git a/Sources/BreezeDynamoDBService/BreezeCodable.swift b/Sources/BreezeDynamoDBService/BreezeCodable.swift index 29f8f46..ccb0006 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,24 @@ // 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 { +/// CodableSendable is a protocol that combines Sendable and Codable. +public protocol CodableSendable: Sendable, Codable { } + +/// BreezeCodable is a protocol that extends CodableSendable to include properties +/// for a key, creation date, and update date. +/// It is designed to be used with Breeze services that require these common fields +/// for items stored in a database, such as DynamoDB. +/// - Parameters: +/// - key: A unique identifier for the item. +/// - createdAt: An optional string representing the creation date of the item. +/// - updatedAt: An optional string representing the last update date of the item. +public protocol BreezeCodable: CodableSendable { var key: String { get set } var createdAt: String? { get set } var updatedAt: String? { get set } diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift new file mode 100644 index 0000000..40b69c9 --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBConfig.swift @@ -0,0 +1,50 @@ +// 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 + +/// BreezeDynamoDBConfig is a configuration structure for Breeze DynamoDB service. +/// It contains the necessary parameters to connect to a DynamoDB instance, including the region, table name, key name, and an optional endpoint. +public struct BreezeDynamoDBConfig: Sendable { + + /// Initializes a new instance of BreezeDynamoDBConfig. + /// - Parameters: + /// - region: The AWS region where the DynamoDB table is located. + /// - tableName: The name of the DynamoDB table. + /// - keyName: The name of the primary key in the DynamoDB table. + /// - endpoint: An optional endpoint URL for the DynamoDB service. If not provided, the default AWS endpoint will be used. + public init( + region: Region, + tableName: String, + keyName: String, + endpoint: String? = nil + ) { + self.region = region + self.tableName = tableName + self.keyName = keyName + self.endpoint = endpoint + } + + /// The AWS region where the DynamoDB table is located. + public let region: Region + + /// The name of the DynamoDB table. + public let tableName: String + + /// The name of the primary key in the DynamoDB table. + public let keyName: String + + /// An optional endpoint URL for the DynamoDB service. + public let endpoint: String? +} diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift new file mode 100644 index 0000000..8e19312 --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManager.swift @@ -0,0 +1,162 @@ +// 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 + +/// BreezeDynamoDBManager is a manager for handling DynamoDB operations in Breeze. +/// It provides methods to create, read, update, delete, and list items in a DynamoDB table. +/// It conforms to the BreezeDynamoDBManaging protocol, which defines the necessary operations for Breeze's DynamoDB integration. +/// - Note: This manager requires a DynamoDB instance, a table name, and a key name to operate. +/// It uses the SotoDynamoDB library to interact with AWS DynamoDB services. +public struct BreezeDynamoDBManager: BreezeDynamoDBManaging { + + /// ServiceError defines the possible errors that can occur when interacting with the BreezeDynamoDBManager. + enum ServiceError: Error { + /// Indicates that the requested item was not found in the DynamoDB table. + case notFound + /// Indicates that the operation failed due to missing parameters, such as a required key. + case missingParameters + } + + /// The name of the primary key in the DynamoDB table. + public let keyName: String + + let db: DynamoDB + let tableName: String + + /// Initializes a new instance of BreezeDynamoDBManager. + /// - Parameters: + /// - db: The DynamoDB instance to use for operations. + /// - tableName: The name of the DynamoDB table to manage. + /// - keyName: The name of the primary key in the DynamoDB table. + public init(db: DynamoDB, tableName: String, keyName: String) { + self.db = db + self.tableName = tableName + self.keyName = keyName + } +} + +public extension BreezeDynamoDBManager { + + /// Creates a new item in the DynamoDB table. + /// - Parameter item: The item to create, conforming to the BreezeCodable protocol. + /// - Returns: The created item, with its `createdAt` and `updatedAt` timestamps set. + func createItem(item: T) async throws -> T { + var item = item + let date = Date() + 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) + } + + /// Reads an item from the DynamoDB table by its key. + /// - Parameter key: The key of the item to read. + /// - Returns: The item with the specified key, conforming to the BreezeCodable protocol. + func readItem(key: String) async throws -> T { + let input = DynamoDB.GetItemInput( + key: [keyName: DynamoDB.AttributeValue.s(key)], + 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 + } + + /// Updates an existing item in the DynamoDB table. + /// - Parameter item: The item to update, conforming to the BreezeCodable protocol. + /// - Returns: The updated item, with its `updatedAt` timestamp set to the current date. + /// - Throws: An error if the item cannot be updated, such as if the item does not exist or the condition fails. + /// - Important: The update operation checks that the `updatedAt` and `createdAt` timestamps match the existing values to prevent concurrent modifications. + func updateItem(item: T) async throws -> T { + var item = item + let oldUpdatedAt = item.updatedAt ?? "" + 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) + } + + /// Deletes an item from the DynamoDB table. + /// - Parameter item: The item to delete, conforming to the BreezeCodable protocol. + /// - Throws: An error if the item cannot be deleted, such as if the item does not exist or the condition fails. + /// - Important: The `updatedAt` and `createdAt` timestamps must be set on the item to ensure safe deletion. This method checks that the `updatedAt` and `createdAt` timestamps match the existing values to prevent concurrent modifications. + func deleteItem(item: T) async throws { + guard let updatedAt = item.updatedAt, + let createdAt = item.createdAt else { + 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 + } + + /// Lists items in the DynamoDB table with optional pagination. + /// - Parameters: + /// - key: An optional key to start the listing from, useful for pagination. + /// - limit: An optional limit on the number of items to return. + /// - Returns: A `ListResponse` containing the items and the last evaluated key for pagination. + /// - Throws: An error if the listing operation fails. + /// - Important: The `key` parameter is used to continue listing from a specific point, and the `limit` parameter controls how many items are returned in one call. + func listItems(key: String?, limit: Int?) async throws -> ListResponse { + var exclusiveStartKey: [String: DynamoDB.AttributeValue]? + if let key { + 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/BreezeDynamoDBManaging.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift new file mode 100644 index 0000000..3733b3b --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeDynamoDBManaging.swift @@ -0,0 +1,73 @@ +// Copyright 2024 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SotoDynamoDB + +/// BreezeDynamoDBManaging is a protocol that defines the methods for managing DynamoDB items. +public protocol BreezeDynamoDBManaging: Sendable { + /// The keyName is the name of the primary key in the DynamoDB table. + var keyName: String { get } + /// Initializes the BreezeDynamoDBManaging with the provided DynamoDB client, table name, and key name. + /// - Parameters: + /// - db: The DynamoDB client to use for database operations. + /// - tableName: The name of the DynamoDB table. + /// - keyName: The name of the primary key in the DynamoDB table. + init(db: DynamoDB, tableName: String, keyName: String) + + /// Creates a new item in the DynamoDB table. + /// - Parameter item: The item to create, conforming to BreezeCodable. + /// - Returns: The created item. + /// - Note: + /// - The item must conform to BreezeCodable. + func createItem(item: Item) async throws -> Item + + /// Reads an item from the DynamoDB table. + /// - Parameter key: The key of the item to read. + /// - Returns: The item read from the table, conforming to BreezeCodable. + /// - Throws: An error if the item could not be read. + /// - Note: + /// - The key should match the primary key defined in the DynamoDB table. + /// - The item must conform to BreezeCodable. + func readItem(key: String) async throws -> Item + + /// Updates an existing item in the DynamoDB table. + /// - Parameter item: The item to update, conforming to BreezeCodable. + /// - Returns: The updated item. + /// - Throws: An error if the item could not be updated. + /// - Note: + /// - The item must have the same primary key as an existing item in the table. + /// - The item must conform to BreezeCodable. + func updateItem(item: Item) async throws -> Item + + /// Deletes an item from the DynamoDB table. + /// - Parameter item: The item to delete, conforming to BreezeCodable. + /// - Throws: An error if the item could not be deleted. + /// - Returns: Void if the item was successfully deleted. + /// - Note: + /// - The item must conform to BreezeCodable. + /// - The item must have the same primary key as an existing item in the table. + func deleteItem(item: Item) async throws + + /// Lists items in the DynamoDB table. + /// - Parameters: + /// - key: An optional key to filter the items. + /// - limit: An optional limit on the number of items to return. + /// - Returns: A ListResponse containing the items and an optional last evaluated key. + /// - Throws: An error if the items could not be listed. + /// - Note: + /// - The items must conform to BreezeCodable. + /// - The key is used to filter the items based on the primary key defined in the DynamoDB table. + /// - The limit is used to control the number of items returned in the response. + func listItems(key: String?, limit: Int?) async throws -> ListResponse +} diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBService.swift index ce4d34d..89e6d79 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,90 @@ // See the License for the specific language governing permissions and // limitations under the License. -import struct Foundation.Date -import NIO import SotoDynamoDB +import AsyncHTTPClient +import ServiceLifecycle +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 - } +/// BreezeDynamoDBServing +/// A protocol that defines the interface for a Breeze DynamoDB service. +/// It provides methods to access the database manager and to gracefully shutdown the service. +public protocol BreezeDynamoDBServing: Actor { + func dbManager() async -> BreezeDynamoDBManaging + func gracefulShutdown() throws } -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 let dbManager: BreezeDynamoDBManaging + private let logger: Logger + private let awsClient: AWSClient + private let httpClient: HTTPClient + private var isShutdown = false + + /// Initializes the BreezeDynamoDBService with the provided configuration. + /// - Parameters: + /// - config: The configuration for the DynamoDB service. + /// - httpConfig: The configuration for the HTTP client. + /// - logger: The logger to use for logging messages. + /// - DBManagingType: The type of the BreezeDynamoDBManaging to use. Defaults to BreezeDynamoDBManager. + /// This initializer sets up the AWS client, HTTP client, and the database manager. + public init( + config: BreezeDynamoDBConfig, + httpConfig: BreezeHTTPClientConfig, + logger: Logger, + DBManagingType: BreezeDynamoDBManaging.Type = BreezeDynamoDBManager.self + ) async { + logger.info("Init DynamoDBService with config...") + logger.info("region: \(config.region)") + logger.info("tableName: \(config.tableName)") + logger.info("keyName: \(config.keyName)") + self.logger = logger + + let timeout = HTTPClient.Configuration.Timeout( + connect: httpConfig.timeout, + read: httpConfig.timeout ) - 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 configuration = HTTPClient.Configuration(timeout: timeout) + self.httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: configuration ) - 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 + self.awsClient = AWSClient(httpClient: httpClient) + let db = SotoDynamoDB.DynamoDB( + client: awsClient, + region: config.region, + endpoint: config.endpoint ) - 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 + self.dbManager = DBManagingType.init( + db: db, + tableName: config.tableName, + keyName: config.keyName ) - let _ = try await db.deleteItem(input) - return + logger.info("DBManager is ready.") } - - 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) - } + + /// Returns the BreezeDynamoDBManaging instance. + public func dbManager() async -> BreezeDynamoDBManaging { + logger.info("Starting DynamoDBService...") + return self.dbManager + } + + /// Gracefully shutdown the service and its components. + /// - Throws: An error if the shutdown process fails. + /// This method ensures that the AWS client and HTTP client are properly shutdown before marking the service as shutdown. + /// It also logs the shutdown process. + /// This method is idempotent; + /// - Important: This method must be called at leat once to ensure that resources are released properly. If the method is not called, it will lead to a crash. + public func gracefulShutdown() throws { + guard !isShutdown else { return } + logger.info("Stopping DynamoDBService...") + try awsClient.syncShutdown() + logger.info("DynamoDBService is stopped.") + logger.info("Stopping HTTPClient...") + try httpClient.syncShutdown() + logger.info("HTTPClient is stopped.") + isShutdown = true } } + diff --git a/Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift b/Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift deleted file mode 100644 index 9713ca4..0000000 --- a/Sources/BreezeDynamoDBService/BreezeDynamoDBServing.swift +++ /dev/null @@ -1,25 +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 SotoDynamoDB - -public protocol BreezeDynamoDBServing { - var keyName: String { get } - init(db: DynamoDB, tableName: String, keyName: String) - func createItem(item: Item) async throws -> Item - func readItem(key: String) async throws -> Item - func updateItem(item: Item) async throws -> Item - func deleteItem(item: Item) async throws - func listItems(key: String?, limit: Int?) async throws -> ListResponse -} diff --git a/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift b/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift new file mode 100644 index 0000000..271d718 --- /dev/null +++ b/Sources/BreezeDynamoDBService/BreezeHTTPClientConfig.swift @@ -0,0 +1,40 @@ +// Copyright 2024 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Logging +import NIOCore + +/// BreezeClientServiceError defines the errors that can occur in the Breeze Client Service. +public enum BreezeClientServiceError: Error { + case invalidHttpClient +} + +/// BreezeHTTPClientConfig is a configuration structure for the Breeze HTTP client. +public struct BreezeHTTPClientConfig: Sendable { + + /// Initializes a new instance of BreezeHTTPClientConfig. + /// - Parameters: + /// - timeout: The timeout duration for HTTP requests. + /// - logger: The logger to use for logging messages. + public init(timeout: TimeAmount, logger: Logger) { + self.timeout = timeout + self.logger = logger + } + + /// The timeout duration for HTTP requests. + public let timeout: TimeAmount + + /// The logger to use for logging messages. + public let logger: Logger +} diff --git a/Sources/BreezeDynamoDBService/Foundation+Extension.swift b/Sources/BreezeDynamoDBService/Foundation+Extension.swift index 817bc29..89dff5f 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. @@ -12,8 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation +import class Foundation.DateFormatter +import struct Foundation.Date +import struct Foundation.TimeZone +/// This file contains extensions for DateFormatter, Date, and String to handle ISO 8601 date formatting and parsing. +/// These extensions provide a convenient way to convert between `Date` objects and their ISO 8601 string representations. extension DateFormatter { static var iso8061: DateFormatter { let formatter = DateFormatter() @@ -24,6 +28,7 @@ extension DateFormatter { } extension Date { + /// Returns a string representation of the date in ISO 8601 format. var iso8601: String { let formatter = DateFormatter.iso8061 return formatter.string(from: self) @@ -31,6 +36,7 @@ extension Date { } extension String { + /// Attempts to parse the string as an ISO 8601 date. var iso8601: Date? { let formatter = DateFormatter.iso8061 return formatter.date(from: self) diff --git a/Sources/BreezeDynamoDBService/ListResponse.swift b/Sources/BreezeDynamoDBService/ListResponse.swift index a1cfbd5..fa94eec 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,31 @@ // 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 { +/// Model representing a paginated list response from a DynamoDB operation. +/// This struct contains an array of items and an optional last evaluated key for pagination. +/// This struct conforms to `CodableSendable`, allowing it to be encoded and decoded for network transmission or storage. +public struct ListResponse: CodableSendable { + + /// Initializes a new instance of `ListResponse`. + /// - Parameters: + /// - items: An array of items returned from the DynamoDB operation. + /// - lastEvaluatedKey: An optional string representing the last evaluated key for pagination. If nil, it indicates that there are no more items to fetch. + /// + /// This initializer is used to create a paginated response for DynamoDB operations. public init(items: [Item], lastEvaluatedKey: String? = nil) { self.items = items self.lastEvaluatedKey = lastEvaluatedKey } - + + /// The items returned from the DynamoDB operation. public let items: [Item] + + /// An optional string representing the last evaluated key for pagination. public let lastEvaluatedKey: String? } diff --git a/Sources/BreezeLambdaAPI/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..5243be9 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. @@ -19,15 +19,15 @@ import class Foundation.JSONEncoder extension APIGatewayV2Response { private static let encoder = JSONEncoder() - /// defaultHeaders /// Override the headers in APIGatewayV2Response - static var defaultHeaders = [ "Content-Type": "application/json" ] + static let defaultHeaders = [ "Content-Type": "application/json" ] + /// A model representing the body of an error response struct BodyError: Codable { let error: String } - /// init + /// Initializer for APIGatewayV2Response with a BodyError /// - Parameters: /// - error: Error /// - statusCode: HTTP Status Code @@ -36,7 +36,7 @@ extension APIGatewayV2Response { self.init(with: bodyError, statusCode: statusCode) } - /// init + /// Initializer for APIGatewayV2Response with an Encodable object /// - Parameters: /// - object: Encodable Object /// - statusCode: HTTP Status Code diff --git a/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift b/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift new file mode 100644 index 0000000..7f622bf --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeAPIConfiguration.swift @@ -0,0 +1,115 @@ +// +// BreezeAPIConfiguration.swift +// BreezeLambdaDynamoDBAPI +// +// Created by Andrea Scuderi on 24/12/2024. +// + +import SotoDynamoDB +import BreezeDynamoDBService +import AWSLambdaRuntime + +/// APIConfiguring is a protocol that defines the configuration for the Breeze Lambda API. +public protocol APIConfiguring { + var dbTimeout: Int64 { get } + func operation() throws -> BreezeOperation + func getConfig() throws -> BreezeDynamoDBConfig +} + +/// BreezeAPIConfiguration is a struct that conforms to APIConfiguring. +/// It provides the necessary configuration for the Breeze Lambda API, including the DynamoDB table name, key name, and AWS region. +/// It also defines the operation handler for Breeze operations. +public struct BreezeAPIConfiguration: APIConfiguring { + + public init() {} + + /// The timeout for database operations in seconds. + public let dbTimeout: Int64 = 30 + + /// The operation handler for Breeze operations. + /// This method retrieves the handler from the environment variable `_HANDLER`. + /// - Throws: `BreezeLambdaAPIError.invalidHandler` if the handler is not found or cannot be parsed. + /// - Returns: A `BreezeOperation` instance initialized with the handler. + /// + /// This method is used to determine the operation that will be executed by the Breeze Lambda API. + /// It expects the `_HANDLER` environment variable to be set, which should contain the handler in the format `module.function`. + /// See BreezeOperation for more details. + public func operation() throws -> BreezeOperation { + guard let handler = Lambda.env("_HANDLER"), + let operation = BreezeOperation(handler: handler) + else { + throw BreezeLambdaAPIError.invalidHandler + } + return operation + } + + /// Returns the configuration for the Breeze DynamoDB service. + /// - Throws: + /// - `BreezeLambdaAPIError.tableNameNotFound` if the DynamoDB table name is not found in the environment variables. + /// - `BreezeLambdaAPIError.keyNameNotFound` if the DynamoDB key name is not found in the environment variables. + /// + /// This method retrieves the AWS region, DynamoDB table name, key name, and optional endpoint from the environment variables. + public func getConfig() throws -> BreezeDynamoDBConfig { + BreezeDynamoDBConfig( + region: currentRegion(), + tableName: try tableName(), + keyName: try keyName(), + endpoint: endpoint() + ) + } + + /// Returns the current AWS region based on the `AWS_REGION` environment variable. + /// If the variable is not set, it defaults to `.useast1`. + /// - Returns: A `Region` instance representing the current AWS region. + /// + /// This method is used to determine the AWS region where the DynamoDB table is located. + func currentRegion() -> Region { + if let awsRegion = Lambda.env("AWS_REGION") { + let value = Region(rawValue: awsRegion) + return value + } else { + return .useast1 + } + } + + /// Returns the DynamoDB table name from the `DYNAMO_DB_TABLE_NAME` environment variable. + /// - Throws: `BreezeLambdaAPIError.tableNameNotFound` if the table name is not found in the environment variables. + /// - Returns: A `String` representing the DynamoDB table name. + /// This method is used to retrieve the name of the DynamoDB table that will be used by the Breeze Lambda API. + /// - Important: The table name is essential for performing operations on the DynamoDB table. + func tableName() throws -> String { + guard let tableName = Lambda.env("DYNAMO_DB_TABLE_NAME") else { + throw BreezeLambdaAPIError.tableNameNotFound + } + return tableName + } + + /// Returns the DynamoDB key name from the `DYNAMO_DB_KEY` environment variable. + /// - Throws: `BreezeLambdaAPIError.keyNameNotFound` if the key name is not found in the environment variables. + /// - Returns: A `String` representing the DynamoDB key name. + /// This method is used to retrieve the name of the primary key in the DynamoDB table that will be used by the Breeze Lambda API. + /// - Important: The key name is essential for identifying items in the DynamoDB table. + func keyName() throws -> String { + guard let tableName = Lambda.env("DYNAMO_DB_KEY") else { + throw BreezeLambdaAPIError.keyNameNotFound + } + return tableName + } + + /// Returns the endpoint for the Breeze Lambda API. + /// If the `LOCALSTACK_ENDPOINT` environment variable is set, it returns that value. + /// - Returns: An optional `String` representing the endpoint URL. + /// - Important: If the `LOCALSTACK_ENDPOINT` environment variable is not set, it returns `nil`, indicating that no custom endpoint is configured. + /// - Note: + /// - This method is useful for testing purposes, especially when running the Breeze Lambda API locally with LocalStack. + /// - LocalStack is a fully functional local AWS cloud stack that allows you to test AWS services locally. + /// - To set it you need to set the `LOCALSTACK_ENDPOINT` environment variable to the URL of your LocalStack instance. + /// - The Default LocalStack endpoint is `http://localhost:4566` + func endpoint() -> String? { + if let localstack = Lambda.env("LOCALSTACK_ENDPOINT"), + !localstack.isEmpty { + return localstack + } + return nil + } +} diff --git a/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift b/Sources/BreezeLambdaAPI/BreezeEmptyResponse.swift index c383bb2..cfb1a85 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. @@ -15,4 +15,5 @@ import class Foundation.JSONDecoder import class Foundation.JSONEncoder +/// A simple struct representing an empty response for Breeze Lambda API. struct BreezeEmptyResponse: Codable {} diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPI.swift index a350d20..67fab77 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,69 @@ // 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 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 +/// BreezeLambdaAPI is a service that integrates with AWS Lambda to provide a Breeze API for DynamoDB operations. +/// It supports operations such as create, read, update, delete, and list items in a DynamoDB table using a BreezeCodable. +/// This Service is designed to work with ServiceLifecycle, allowing it to be run and stopped gracefully. +public actor BreezeLambdaAPI: Service { + + let logger = Logger(label: "service-group-breeze-lambda-api") + let timeout: TimeAmount + private let serviceGroup: ServiceGroup + private let apiConfig: any APIConfiguring + + /// Initializes the BreezeLambdaAPI with the provided API configuration. + /// - Parameter apiConfig: An object conforming to `APIConfiguring` that provides the necessary configuration for the Breeze API. + /// - Throws: An error if the configuration is invalid or if the service fails to initialize. + /// + /// This initializer sets up the Breeze Lambda API service with the specified configuration, including the DynamoDB service and the operation to be performed. + /// + /// - Note: + /// - The `apiConfig` parameter must conform to the `APIConfiguring` protocol, which provides the necessary configuration details for the Breeze API. + /// - The default implementation uses `BreezeAPIConfiguration`, but you can provide your own implementation if needed. + public init(apiConfig: APIConfiguring = BreezeAPIConfiguration()) async throws { + do { + self.apiConfig = apiConfig + self.timeout = .seconds(apiConfig.dbTimeout) + let config = try apiConfig.getConfig() + let httpConfig = BreezeHTTPClientConfig( + timeout: .seconds(60), + logger: logger + ) + let operation = try apiConfig.operation() + let dynamoDBService = await BreezeDynamoDBService( + config: config, + httpConfig: httpConfig, + logger: logger + ) + let breezeLambdaService = BreezeLambdaService( + dynamoDBService: dynamoDBService, + operation: operation, + logger: logger + ) + self.serviceGroup = ServiceGroup( + services: [breezeLambdaService], + gracefulShutdownSignals: [.sigterm, .sigint], + 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) + + /// Runs the BreezeLambdaAPI service. + /// This method starts the internal ServiceGroup and begins processing requests. + /// - Throws: An error if the service fails to start or if an issue occurs during execution. + /// + /// The internal ServiceGroup will handle the lifecycle of the BreezeLambdaAPI, including starting and stopping the service gracefully. + public func run() async throws { + logger.info("Starting BreezeLambdaAPI...") + try await serviceGroup.run() + logger.info("BreezeLambdaAPI is stopped successfully") } } diff --git a/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift b/Sources/BreezeLambdaAPI/BreezeLambdaAPIError.swift index 2d476bb..2d10e67 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,12 +12,44 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif +/// BreezeLambdaAPIError is an enumeration that defines various errors that can occur in the Breeze Lambda API. enum BreezeLambdaAPIError: Error { + /// Indicates that an item is invalid. case invalidItem + /// Indicates that the DynamoDB table name is not found in the environment. case tableNameNotFound + /// Indicates that the key name for the DynamoDB table is not found in the environment. case keyNameNotFound + /// Indicates that the request made to the API is invalid. case invalidRequest + /// Indicates that the _HANDLER environment variable is invalid or missing. case invalidHandler + /// Indicates that the service is invalid, possibly due to misconfiguration or an unsupported operation. + case invalidService +} + +/// Extension for BreezeLambdaAPIError to provide localized error descriptions. +extension BreezeLambdaAPIError: LocalizedError { + var errorDescription: String? { + switch self { + 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..1bdc95c 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,34 @@ import AWSLambdaRuntime import BreezeDynamoDBService import Logging -struct BreezeLambdaHandler { +/// BreezeLambdaHandler implements a Lambda handler for Breeze operations. +/// It conforms to the `LambdaHandler` protocol and is generic over a type `T` that conforms to `BreezeCodable`. +/// +/// This handler supports the following operations: +/// +/// - Create: Creates a new item in the DynamoDB table. +/// - Read: Reads an item from the DynamoDB table based on the provided key. +/// - Update: Updates an existing item in the DynamoDB table. +/// - Delete: Deletes an item from the DynamoDB table based on the provided key and timestamps. +/// - List: Lists items in the DynamoDB table with optional pagination. +struct BreezeLambdaHandler: LambdaHandler, Sendable { typealias Event = APIGatewayV2Request typealias Output = APIGatewayV2Response - 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 { + + /// Lambda handler for Breeze operations. + /// - Parameters: + /// - event: The event containing the API Gateway request. + /// - context: The Lambda context providing information about the invocation. + /// + /// This initializer sets up the Breeze Lambda handler with the specified DynamoDB manager and operation. + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { switch self.operation { case .create: return await self.createLambdaHandler(context: context, event: event) @@ -43,39 +59,42 @@ struct BreezeLambdaHandler { } } - func createLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + /// Lambda handler for creating an item in the DynamoDB table. + func createLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let item: T = try? event.bodyObject() else { let error = BreezeLambdaAPIError.invalidRequest 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 { + /// Lambda handler for reading an item from the DynamoDB table. + func readLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let key = event.pathParameters?[keyName] else { let error = BreezeLambdaAPIError.invalidRequest 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 { + /// Lambda handler for updating an item in the DynamoDB table. + func updateLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let item: T = try? event.bodyObject() else { let error = BreezeLambdaAPIError.invalidRequest 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 +107,8 @@ struct BreezeLambdaHandler { var updatedAt: String? } - func deleteLambdaHandler(context: AWSLambdaRuntimeCore.LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { + /// Lambda handler for deleting an item from the DynamoDB table. + func deleteLambdaHandler(context: LambdaContext, event: APIGatewayV2Request) async -> APIGatewayV2Response { guard let key = event.pathParameters?[keyName], let createdAt = event.queryStringParameters?["createdAt"], let updatedAt = event.queryStringParameters?["updatedAt"] else { @@ -97,18 +117,19 @@ 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 { + /// Lambda handler for listing items in the DynamoDB table. + 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..e0615c8 --- /dev/null +++ b/Sources/BreezeLambdaAPI/BreezeLambdaService.swift @@ -0,0 +1,104 @@ +// 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 +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// BreezeLambdaService is an actor that provides a service for handling AWS Lambda events using BreezeCodable models. +/// It conforms to the `Service` protocol and implements the `handler` method to process incoming events. +/// It manages the lifecycle of a BreezeLambdaHandler, which is responsible for handling the actual business logic. +/// It also provides a method to run the service and handle graceful shutdowns. +/// it operates on a BreezeCodable model type `T` that conforms to the BreezeCodable protocol. +actor BreezeLambdaService: Service { + + /// DynamoDBService is an instance of BreezeDynamoDBServing that provides access to the DynamoDB database manager. + let dynamoDBService: BreezeDynamoDBServing + /// Operation is an instance of BreezeOperation that defines the operation to be performed by the BreezeLambdaHandler. + let operation: BreezeOperation + /// Logger is an instance of Logger for logging messages during the service's operation. + let logger: Logger + + /// Initializes a new instance of `BreezeLambdaService`. + /// - Parameters: + /// - dynamoDBService: An instance of `BreezeDynamoDBServing` that provides access to the DynamoDB database manager. + /// - operation: The `BreezeOperation` that defines the operation to be performed by the BreezeLambdaHandler. + /// - logger: A `Logger` instance for logging messages during the service's operation. + init(dynamoDBService: BreezeDynamoDBServing, operation: BreezeOperation, logger: Logger) { + self.dynamoDBService = dynamoDBService + self.operation = operation + self.logger = logger + } + + /// BreezeLambdaHandler is an optional instance of BreezeLambdaHandler that will handle the actual business logic. + var breezeApi: BreezeLambdaHandler? + + /// Handler method that processes incoming AWS Lambda events. + func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { + guard let breezeApi else { throw BreezeLambdaAPIError.invalidHandler } + return try await breezeApi.handle(event, context: context) + } + + /// Runs the BreezeLambdaService, initializing the BreezeLambdaHandler and starting the Lambda runtime. + /// - Throws: An error if the service fails to initialize or run. + func run() async throws { + let dbManager = await dynamoDBService.dbManager() + let breezeApi = BreezeLambdaHandler(dbManager: dbManager, operation: self.operation) + self.breezeApi = breezeApi + logger.info("Starting BreezeLambdaService...") + let runtime = LambdaRuntime(body: handler) + try await runTaskWithCancellationOnGracefulShutdown { + do { + try await runtime.run() + } catch { + self.logger.error("\(error.localizedDescription)") + throw error + } + } onGracefulShutdown: { + self.logger.info("Gracefully stoping BreezeLambdaService ...") + try await self.dynamoDBService.gracefulShutdown() + self.logger.info("BreezeLambdaService is stopped.") + } + } + + /// Runs a task with cancellation on graceful shutdown. + /// - Note: It's required to allow a full process shutdown without leaving tasks hanging. + private func runTaskWithCancellationOnGracefulShutdown( + operation: @escaping @Sendable () async throws -> Void, + onGracefulShutdown: () async throws -> Void + ) async throws { + let (cancelOrGracefulShutdown, cancelOrGracefulShutdownContinuation) = AsyncStream.makeStream() + let task = Task { + try await withTaskCancellationOrGracefulShutdownHandler { + try await operation() + } onCancelOrGracefulShutdown: { + cancelOrGracefulShutdownContinuation.yield() + cancelOrGracefulShutdownContinuation.finish() + } + } + for await _ in cancelOrGracefulShutdown { + try await onGracefulShutdown() + task.cancel() + } + } +} diff --git a/Sources/BreezeLambdaAPI/BreezeOperation.swift b/Sources/BreezeLambdaAPI/BreezeOperation.swift index 4e50c8e..22aa6eb 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,13 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -enum BreezeOperation: String { +/// BreezeOperation is an enumeration that defines the operations supported by Breeze Lambda API. +/// It includes operations such as create, read, update, delete, and list. +public enum BreezeOperation: String, Sendable { case create case read case update case delete case list + /// Initializes a BreezeOperation from a handler string. + /// + /// - Parameter handler: A string representing the handler, typically in the format "module.operation". + /// - Returns: An optional BreezeOperation if the handler string can be parsed successfully. + /// - Note: This initializer extracts the operation from the handler string by splitting it at the last dot (.) and matching it to a BreezeOperation case. init?(handler: String) { guard let value = handler.split(separator: ".").last, let operation = BreezeOperation(rawValue: String(value)) diff --git a/Sources/BreezeLambdaDynamoDBAPI.docc/BreezeLambdaDynamoDBAPI.md b/Sources/BreezeLambdaDynamoDBAPI.docc/BreezeLambdaDynamoDBAPI.md new file mode 100644 index 0000000..17a884b --- /dev/null +++ b/Sources/BreezeLambdaDynamoDBAPI.docc/BreezeLambdaDynamoDBAPI.md @@ -0,0 +1,16 @@ +# ``BreezeLambdaDynamoDBAPI`` + +Implement a CRUD API based on AWS Lambda, APIGateway, DynamoDB. + +@Metadata { + @PageImage(purpose: icon, source: "Icon") +} + +## Essentials + +Learn how you can make the most of BreezeLambdaDynamoDBAPI with these guides: + +@Links(visualStyle: detailedGrid) { + - + - +} diff --git a/Sources/BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift b/Sources/BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift new file mode 100644 index 0000000..4fcf9e7 --- /dev/null +++ b/Sources/BreezeLambdaItemAPI/BreezeLambdaItemAPI.swift @@ -0,0 +1,83 @@ +// 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 + +/// The BreezeLambdaItemAPI is an example of a Breeze Lambda API that interacts with DynamoDB to manage items. +/// Use this example to understand how to create a Breeze Lambda API that can list, create, update, and delete items in a DynamoDB table. + +/// The Item struct represents an item in the DynamoDB table. +/// It conforms to Codable for easy encoding and decoding to/from JSON. +struct Item: Codable { + public var key: String + public let name: String + 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 + } +} + +/// BreezeCodable is a protocol that allows the Item struct to be used with Breeze Lambda API. +extension Item: BreezeCodable { } + +/// APIConfiguration is a struct that conforms to APIConfiguring. +/// It provides the configuration for the Breeze Lambda API, including the DynamoDB table name, key name, and endpoint. +/// It also specifies the operation to be performed, which in this case is listing items. +struct APIConfiguration: APIConfiguring { + let dbTimeout: Int64 = 30 + func operation() throws -> BreezeOperation { + .list + } + + /// Get the configuration for the DynamoDB service. + /// It specifies the region, table name, key name, and endpoint. + /// In this example, it uses a local Localstack endpoint for testing purposes. + /// You can change the region, table name, key name, and endpoint as needed for your application. + /// Remove the endpoint for production use. + func getConfig() throws -> BreezeDynamoDBConfig { + BreezeDynamoDBConfig(region: .useast1, tableName: "Breeze", keyName: "itemKey", endpoint: "http://127.0.0.1:4566") + } +} + +@main +struct BreezeLambdaItemAPI { + static func main() async throws { +#if DEBUG + do { + let lambdaAPIService = try await BreezeLambdaAPI(apiConfig: APIConfiguration()) + try await lambdaAPIService.run() + } catch { + print(error.localizedDescription) + } +#else + // In production, you can run the BreezeLambdaAPI without the API configuration. + // This will use the default configuration for the BreezeDynamoDBService. + // Make sure to set the environment variables for the DynamoDB service: + // DYNAMODB_TABLE_NAME, DYNAMODB_KEY_NAME, and AWS_REGION. + do { + try await BreezeLambdaAPI().run() + } catch { + print(error.localizedDescription) + } +#endif + } +} diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift new file mode 100644 index 0000000..aaa9ac7 --- /dev/null +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerMock.swift @@ -0,0 +1,84 @@ +// Copyright 2024 (c) Andrea Scuderi - https://github.com/swift-serverless +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import BreezeDynamoDBService +import SotoDynamoDB + +actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { + let keyName: String + + enum BreezeDynamoDBManagerError: Error { + case invalidRequest + case invalidItem + } + + private var response: (any BreezeCodable)? + private var keyedResponse: (any BreezeCodable)? + + func setupMockResponse(response: (any BreezeCodable)?, keyedResponse: (any BreezeCodable)?) { + self.keyedResponse = keyedResponse + self.response = response + } + + init(db: SotoDynamoDB.DynamoDB, tableName: String, keyName: String) { + self.keyName = keyName + } + + func createItem(item: T) async throws -> T { + guard let response = self.response as? T else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return response + } + + func readItem(key: String) async throws -> T { + guard let response = self.keyedResponse as? T, + response.key == key + else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return response + } + + func updateItem(item: T) async throws -> T { + guard let response = self.keyedResponse as? T, + response.key == item.key + else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return response + } + + func deleteItem(item: T) async throws { + guard let response = self.keyedResponse, + response.key == item.key, + response.createdAt == item.createdAt, + response.updatedAt == item.updatedAt + else { + throw BreezeDynamoDBManagerError.invalidRequest + } + return + } + + var limit: Int? + var exclusiveKey: String? + func listItems(key: String?, limit: Int?) async throws -> ListResponse { + guard let response = self.response as? T else { + throw BreezeDynamoDBManagerError.invalidItem + } + self.limit = limit + self.exclusiveKey = key + return ListResponse(items: [response], lastEvaluatedKey: key) + } +} diff --git a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift new file mode 100644 index 0000000..7e16164 --- /dev/null +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBManagerTests.swift @@ -0,0 +1,267 @@ +// 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 DynamoDBErrorType.conditionalCheckFailedException") + } catch { + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) + } + 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 ServiceError.notfound when Item is missing") + } catch { + let dynamoDBError = try #require(error as? BreezeDynamoDBManager.ServiceError) + #expect(dynamoDBError == .notFound) + } + 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 AWSResponseError ValidationException") + } catch { + let dynamoDBError = try #require(error as? AWSResponseError) + #expect(dynamoDBError.errorCode == "ValidationException") + } + + do { + let _: Product = try await sut.updateItem(item: product2022) + Issue.record("It should throw AWSResponseError ValidationException") + } catch { + let dynamoDBError = try #require(error as? AWSResponseError) + #expect(dynamoDBError.errorCode == "ValidationException") + } + 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 DynamoDBErrorType.conditionalCheckFailedException") + } catch { + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) + } + 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 { + let dynamoDBError = try #require(error as? BreezeDynamoDBManager.ServiceError) + #expect(dynamoDBError == .missingParameters) + } + 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 { + let dynamoDBError = try #require(error as? BreezeDynamoDBManager.ServiceError) + #expect(dynamoDBError == .missingParameters) + } + 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 DynamoDBErrorType.conditionalCheckFailedException") + } catch { + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) + } + 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 DynamoDBErrorType.conditionalCheckFailedException") + } catch { + let dynamoDBError = try #require(error as? DynamoDBErrorType) + #expect(dynamoDBError == .conditionalCheckFailedException) + } + 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 index ca0f32c..1c0509c 100644 --- a/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.swift +++ b/Tests/BreezeDynamoDBServiceTests/BreezeDynamoDBServiceTests.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,195 +12,61 @@ // See the License for the specific language governing permissions and // limitations under the License. -import SotoCore import SotoDynamoDB -import XCTest +import AsyncHTTPClient +import Logging +import Testing @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) - } +@Suite +struct BreezeDynamoDBServiceTests { + @Test + func testInitPrepareBreezeDynamoDBManager() async throws { + let sut = await makeBreezeDynamoDBConfig() + let manager = await sut.dbManager() + #expect(manager is BreezeDynamoDBManager, "Expected BreezeDynamoDBManager instance") + try await sut.gracefulShutdown() } - 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) - } + @Test + func testGracefulShutdownCanBeCalledMultipleTimes() async throws { + let sut = await makeBreezeDynamoDBConfig() + try await sut.gracefulShutdown() + try await sut.gracefulShutdown() } - 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) - } + @Test + func testMockInjection() async throws { + let config = BreezeDynamoDBConfig( + region: .useast1, + tableName: "TestTable", + keyName: "TestKey", + ) + let logger = Logger(label: "BreezeDynamoDBServiceTests") + let httpConfig = BreezeHTTPClientConfig(timeout: .seconds(10), logger: logger) + let sut = await BreezeDynamoDBService( + config: config, + httpConfig: httpConfig, + logger: logger, + DBManagingType: BreezeDynamoDBManagerMock.self + ) + let manager = await sut.dbManager() + #expect(manager is BreezeDynamoDBManagerMock, "Expected BreezeDynamoDBManager instance") + try await sut.gracefulShutdown() } - 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)) + private func makeBreezeDynamoDBConfig() async -> BreezeDynamoDBService { + let config = BreezeDynamoDBConfig( + region: .useast1, + tableName: "TestTable", + keyName: "TestKey", + ) + let logger = Logger(label: "BreezeDynamoDBServiceTests") + let httpConfig = BreezeHTTPClientConfig(timeout: .seconds(10), logger: logger) + return await BreezeDynamoDBService( + config: config, + httpConfig: httpConfig, + logger: logger, + ) } } diff --git a/Tests/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/BreezeDynamoDBServiceTests/Utils.swift b/Tests/BreezeDynamoDBServiceTests/Utils.swift deleted file mode 100644 index 822b27c..0000000 --- a/Tests/BreezeDynamoDBServiceTests/Utils.swift +++ /dev/null @@ -1,26 +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 Foundation - -func getEnvironmentVar(name: String) -> String? { - guard let envValue = getenv(name) else { - return nil - } - return String(cString: envValue) -} - -func setEnvironmentVar(name: String, value: String, overwrite: Bool) { - setenv(name, value, overwrite ? 1 : 0) -} diff --git a/Tests/BreezeLambdaAPITests/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 69% rename from Tests/BreezeLambdaAPITests/BreezeDynamoDBServiceMock.swift rename to Tests/BreezeLambdaAPITests/BreezeDynamoDBManagerMock.swift index c4758f6..a6b0499 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,43 +16,48 @@ import BreezeDynamoDBService @testable import BreezeLambdaAPI import SotoDynamoDB -struct BreezeDynamoDBServiceMock: BreezeDynamoDBServing { - var keyName: String - - static var response: (any BreezeCodable)? - static var keyedResponse: (any BreezeCodable)? - +actor BreezeDynamoDBManagerMock: BreezeDynamoDBManaging { + let keyName: String + + private var response: (any BreezeCodable)? + private var keyedResponse: (any BreezeCodable)? + + func setupMockResponse(response: (any BreezeCodable)?, keyedResponse: (any BreezeCodable)?) { + self.keyedResponse = keyedResponse + self.response = response + } + 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 } return response } - + 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 } return response } - + 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 @@ -61,22 +66,15 @@ 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..bd55e44 --- /dev/null +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaAPIServiceTests.swift @@ -0,0 +1,90 @@ +// 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 + + let (gracefulStream, continuation) = AsyncStream.makeStream() + + try await withThrowingTaskGroup(of: Void.self) { group in + let sut = try await BreezeLambdaAPI(apiConfig: APIConfiguration()) + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + gracefulShutdownTestTrigger.triggerGracefulShutdown() + } + group.addTask { + try await withGracefulShutdownHandler { + try await sut.run() + print("BreezeLambdaAPIService started successfully") + } onGracefulShutdown: { + logger.info("On Graceful Shutdown") + continuation.yield() + continuation.finish() + } + } + for await _ in gracefulStream { + logger.info("Graceful shutdown stream received") + 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 await 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..a245144 --- /dev/null +++ b/Tests/BreezeLambdaAPITests/BreezeLambdaHandlerTests.swift @@ -0,0 +1,346 @@ +// 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 +@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 _: BreezeEmptyResponse = try apiResponse.decodeBody() + #expect(apiResponse.statusCode == .ok) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + } + + @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 _: BreezeEmptyResponse = try apiResponse.decodeBody() + #expect(apiResponse.statusCode == .notFound) + #expect(apiResponse.headers == [ "Content-Type": "application/json" ]) + } + + @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..e5d45b0 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,67 @@ // limitations under the License. import AWSLambdaEvents -import AWSLambdaRuntime -@testable import AWSLambdaRuntimeCore -import AWSLambdaTesting +@testable import AWSLambdaRuntime +import BreezeDynamoDBService +@testable import BreezeLambdaAPI import Logging import NIO +import ServiceLifecycle +import ServiceLifecycleTestKit +import Logging +import Testing +import SotoDynamoDB +import AsyncHTTPClient +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif -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/Tests/BreezeLambdaAPITests/Utils.swift b/Tests/BreezeLambdaAPITests/Utils.swift deleted file mode 100644 index d98e67c..0000000 --- a/Tests/BreezeLambdaAPITests/Utils.swift +++ /dev/null @@ -1,20 +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 Foundation - -func setEnvironmentVar(name: String, value: String, overwrite: Bool) { - setenv(name, value, overwrite ? 1 : 0) -} - diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b0e9cb..c1096cc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:5.7.3-amazonlinux2 as builder +FROM swift:6.1.2-amazonlinux2 as builder RUN yum -y update && \ yum -y install git make