diff --git a/Examples/SAM/.gitignore b/Examples/SAM/.gitignore new file mode 100644 index 00000000..5970ff59 --- /dev/null +++ b/Examples/SAM/.gitignore @@ -0,0 +1,8 @@ +Makefile +TODO +notes.md +sam.yaml +sam.json +template.yaml +template.json +samconfig.toml \ No newline at end of file diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift new file mode 100644 index 00000000..ecb9aa99 --- /dev/null +++ b/Examples/SAM/Deploy.swift @@ -0,0 +1,112 @@ +import AWSLambdaDeploymentDescriptor + +// example of a shared resource +let sharedQueue = Queue( + logicalName: "SharedQueue", + physicalName: "swift-lambda-shared-queue") + +// example of common environment variables +let sharedEnvironmentVariables = ["LOG_LEVEL": "debug"] + +let validEfsArn = + "arn:aws:elasticfilesystem:eu-central-1:012345678901:access-point/fsap-abcdef01234567890" + +// the deployment descriptor +DeploymentDescriptor { + + // an optional description + "Description of this deployment descriptor" + + // Create a lambda function exposed through a REST API + Function(name: "HttpApiLambda") { + + // an optional description + "Description of this function" + + EventSources { + + // example of a catch all api + HttpApi() + + // example of an API for a specific HTTP verb and path + // HttpApi(method: .GET, path: "/test") + + } + + EnvironmentVariables { + [ + "NAME1": "VALUE1", + "NAME2": "VALUE2", + ] + + // shared environment variables declared upfront + sharedEnvironmentVariables + } + } + + // Example Function modifiers: + + // .autoPublishAlias() + // .ephemeralStorage(2048) + // .eventInvoke(onSuccess: "arn:aws:sqs:eu-central-1:012345678901:lambda-test", + // onFailure: "arn:aws:lambda:eu-central-1:012345678901:lambda-test", + // maximumEventAgeInSeconds: 600, + // maximumRetryAttempts: 3) + // .fileSystem(validEfsArn, mountPoint: "/mnt/path1") + // .fileSystem(validEfsArn, mountPoint: "/mnt/path2") + + // Create a Lambda function exposed through an URL + // you can invoke it with a signed request, for example + // curl --aws-sigv4 "aws:amz:eu-central-1:lambda" \ + // --user $AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY \ + // -H 'content-type: application/json' \ + // -d '{ "example": "test" }' \ + // "$FUNCTION_URL?param1=value1¶m2=value2" + Function(name: "UrlLambda") { + "A Lambda function that is directly exposed as an URL, with IAM authentication" + } + .urlConfig(authType: .iam) + + // Create a Lambda function triggered by messages on SQS + Function(name: "SQSLambda", architecture: .arm64) { + + EventSources { + + // this will reference an existing queue by its Arn + // Sqs("arn:aws:sqs:eu-central-1:012345678901:swift-lambda-shared-queue") + + // // this will create a new queue resource + Sqs("swift-lambda-queue-name") + + // // this will create a new queue resource, with control over physical queue name + // Sqs() + // .queue(logicalName: "LambdaQueueResource", physicalName: "swift-lambda-queue-resource") + + // // this references a shared queue resource created at the top of this deployment descriptor + // // the queue resource will be created automatically, you do not need to add `sharedQueue` as a resource + // Sqs(sharedQueue) + } + + EnvironmentVariables { + sharedEnvironmentVariables + } + } + + // + // Additional resources + // + // Create a SQS queue + Queue( + logicalName: "TopLevelQueueResource", + physicalName: "swift-lambda-top-level-queue") + + // Create a DynamoDB table + Table( + logicalName: "SwiftLambdaTable", + physicalName: "swift-lambda-table", + primaryKeyName: "id", + primaryKeyType: "String") + + // example modifiers + // .provisionedThroughput(readCapacityUnits: 10, writeCapacityUnits: 99) +} diff --git a/Examples/SAM/HttpApiLambda/Lambda.swift b/Examples/SAM/HttpApiLambda/Lambda.swift new file mode 100644 index 00000000..7deea5e7 --- /dev/null +++ b/Examples/SAM/HttpApiLambda/Lambda.swift @@ -0,0 +1,52 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2021 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime +import Foundation + +@main +struct HttpApiLambda: LambdaHandler { + init() {} + init(context: LambdaInitializationContext) async throws { + context.logger.info( + "Log Level env var : \(ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "info" )") + } + + // the return value must be either APIGatewayV2Response or any Encodable struct + func handle(_ event: APIGatewayV2Request, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> APIGatewayV2Response { + + var header = HTTPHeaders() + do { + context.logger.debug("HTTP API Message received") + + header["content-type"] = "application/json" + + // echo the request in the response + let data = try JSONEncoder().encode(event) + let response = String(data: data, encoding: .utf8) + + // if you want control on the status code and headers, return an APIGatewayV2Response + // otherwise, just return any Encodable struct, the runtime will wrap it for you + return APIGatewayV2Response(statusCode: .ok, headers: header, body: response) + + } catch { + // should never happen as the decoding was made by the runtime + // when the input event is malformed, this function is not even called + header["content-type"] = "text/plain" + return APIGatewayV2Response(statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") + + } + } +} diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift new file mode 100644 index 00000000..8218335c --- /dev/null +++ b/Examples/SAM/Package.swift @@ -0,0 +1,80 @@ +// swift-tools-version:5.7 + +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2021 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import class Foundation.ProcessInfo // needed for CI to test the local version of the library +import PackageDescription + +let package = Package( + name: "swift-aws-lambda-runtime-example", + platforms: [ + .macOS(.v12) + ], + products: [ + .executable(name: "HttpApiLambda", targets: ["HttpApiLambda"]), + .executable(name: "SQSLambda", targets: ["SQSLambda"]), + .executable(name: "UrlLambda", targets: ["UrlLambda"]) + ], + dependencies: [ + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") + ], + targets: [ + .executableTarget( + name: "HttpApiLambda", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], + path: "./HttpApiLambda" + ), + .executableTarget( + name: "UrlLambda", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], + path: "./UrlLambda" + ), + .executableTarget( + name: "SQSLambda", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], + path: "./SQSLambda" + ), + .testTarget( + name: "LambdaTests", + dependencies: [ + "HttpApiLambda", "SQSLambda", + .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), + ], + // testing data + resources: [ + .process("data/apiv2.json"), + .process("data/sqs.json") + ] + ) + ] +) + +// for CI to test the local version of the library +if ProcessInfo.processInfo.environment["LAMBDA_USE_LOCAL_DEPS"] != nil { + package.dependencies = [ + .package(name: "swift-aws-lambda-runtime", path: "../.."), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") + ] +} diff --git a/Examples/SAM/SQSLambda/Lambda.swift b/Examples/SAM/SQSLambda/Lambda.swift new file mode 100644 index 00000000..6e39a418 --- /dev/null +++ b/Examples/SAM/SQSLambda/Lambda.swift @@ -0,0 +1,40 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2021 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime +import Foundation + +@main +struct SQSLambda: LambdaHandler { + typealias Event = SQSEvent + typealias Output = Void + + init() {} + init(context: LambdaInitializationContext) async throws { + context.logger.info( + "Log Level env var : \(ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "info" )") + } + + func handle(_ event: Event, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> Output { + + context.logger.info("Log Level env var : \(ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "not defined" )" ) + context.logger.debug("SQS Message received, with \(event.records.count) record") + + for msg in event.records { + context.logger.debug("Message ID : \(msg.messageId)") + context.logger.debug("Message body : \(msg.body)") + } + } +} diff --git a/Examples/SAM/Tests/LambdaTests/HttpApiLambdaTest.swift b/Examples/SAM/Tests/LambdaTests/HttpApiLambdaTest.swift new file mode 100644 index 00000000..590df2d0 --- /dev/null +++ b/Examples/SAM/Tests/LambdaTests/HttpApiLambdaTest.swift @@ -0,0 +1,46 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2021 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime +import AWSLambdaTesting +import XCTest +@testable import HttpApiLambda + +class HttpApiLambdaTests: LambdaTest { + + func testHttpAPiLambda() async throws { + + // given + let eventData = try self.loadTestData(file: .apiGatewayV2) + let event = try JSONDecoder().decode(APIGatewayV2Request.self, from: eventData) + + do { + // when + let result = try await Lambda.test(HttpApiLambda.self, with: event) + + // then + XCTAssertEqual(result.statusCode.code, 200) + XCTAssertNotNil(result.headers) + if let headers = result.headers { + XCTAssertNotNil(headers["content-type"]) + if let contentType = headers["content-type"] { + XCTAssertTrue(contentType == "application/json") + } + } + } catch { + XCTFail("Lambda invocation should not throw error : \(error)") + } + } +} diff --git a/Examples/SAM/Tests/LambdaTests/LambdaTest.swift b/Examples/SAM/Tests/LambdaTests/LambdaTest.swift new file mode 100644 index 00000000..dff1ce54 --- /dev/null +++ b/Examples/SAM/Tests/LambdaTests/LambdaTest.swift @@ -0,0 +1,22 @@ +import Foundation +import XCTest + +enum TestData: String { + case apiGatewayV2 = "apiv2" + case sqs = "sqs" +} + +class LambdaTest: XCTestCase { + // return the URL of a test file + // files are copied to the bundle during build by the `resources` directive in `Package.swift` + private func urlForTestData(file: TestData) throws -> URL { + let filePath = Bundle.module.path(forResource: file.rawValue, ofType: "json")! + return URL(fileURLWithPath: filePath) + } + + // load a test file added as a resource to the executable bundle + func loadTestData(file: TestData) throws -> Data { + // load list from file + return try Data(contentsOf: urlForTestData(file: file)) + } +} diff --git a/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift b/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift new file mode 100644 index 00000000..e28b4071 --- /dev/null +++ b/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift @@ -0,0 +1,40 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2021 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime +import AWSLambdaTesting +import XCTest +@testable import SQSLambda + +class SQSLambdaTests: LambdaTest { + + func testSQSLambda() async throws { + + // given + let eventData = try self.loadTestData(file: .sqs) + let event = try JSONDecoder().decode(SQSEvent.self, from: eventData) + + // when + do { + try await Lambda.test(SQSLambda.self, with: event) + } catch { + XCTFail("Lambda invocation should not throw error : \(error)") + } + + // then + // SQS Lambda returns Void + + } +} diff --git a/Examples/SAM/Tests/LambdaTests/data/apiv2.json b/Examples/SAM/Tests/LambdaTests/data/apiv2.json new file mode 100644 index 00000000..d78908c3 --- /dev/null +++ b/Examples/SAM/Tests/LambdaTests/data/apiv2.json @@ -0,0 +1,46 @@ +{ + "version": "2.0", + "queryStringParameters": { + "arg2": "value2", + "arg1": "value1" + }, + "isBase64Encoded": false, + "requestContext": { + "timeEpoch": 1671601639995, + "apiId": "x6v980zzkh", + "http": { + "protocol": "HTTP\/1.1", + "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\/20100101 Firefox\/108.0", + "sourceIp": "1.2.3.4", + "path": "\/test", + "method": "GET" + }, + "domainName": "x6v980zzkh.execute-api.eu-central-1.amazonaws.com", + "accountId": "012345678901", + "requestId": "de2cRil5liAEM5Q=", + "time": "21\/Dec\/2022:05:47:19 +0000", + "domainPrefix": "x6v980zzkh", + "stage": "$default" + }, + "rawPath": "\/test", + "headers": { + "accept-encoding": "gzip, deflate, br", + "host": "x6v980zzkh.execute-api.eu-central-1.amazonaws.com", + "accept-language": "en-US,en;q=0.8,fr-FR;q=0.5,fr;q=0.3", + "sec-fetch-dest": "document", + "x-amzn-trace-id": "Root=1-63a29de7-371407804cbdf89323be4902", + "x-forwarded-for": "1.2.3.4", + "accept": "text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/avif,image\/webp,*\/*;q=0.8", + "sec-fetch-site": "none", + "content-length": "0", + "user-agent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\/20100101 Firefox\/108.0", + "sec-fetch-user": "?1", + "x-forwarded-port": "443", + "dnt": "1", + "x-forwarded-proto": "https", + "sec-fetch-mode": "navigate", + "upgrade-insecure-requests": "1" + }, + "rawQueryString": "arg1=value1&arg2=value2", + "routeKey": "$default" +} \ No newline at end of file diff --git a/Examples/SAM/Tests/LambdaTests/data/sqs.json b/Examples/SAM/Tests/LambdaTests/data/sqs.json new file mode 100644 index 00000000..f697abde --- /dev/null +++ b/Examples/SAM/Tests/LambdaTests/data/sqs.json @@ -0,0 +1,20 @@ +{ + "Records": [ + { + "messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78", + "receiptHandle": "MessageReceiptHandle", + "body": "Hello from SQS!", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "7b270e59b47ff90a553787216d55d91d", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue", + "awsRegion": "us-east-1" + } + ] +} diff --git a/Examples/SAM/UrlLambda/Lambda.swift b/Examples/SAM/UrlLambda/Lambda.swift new file mode 100644 index 00000000..d697c869 --- /dev/null +++ b/Examples/SAM/UrlLambda/Lambda.swift @@ -0,0 +1,55 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2021 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime +import Foundation + +@main +struct UrlLambda: LambdaHandler { + init() {} + init(context: LambdaInitializationContext) async throws { + context.logger.info( + "Log Level env var : \(ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "info" )") + } + + // the return value must be either APIGatewayV2Response or any Encodable struct + func handle(_ event: FunctionURLRequest, context: AWSLambdaRuntimeCore.LambdaContext) async throws + -> FunctionURLResponse + { + + var header = HTTPHeaders() + do { + context.logger.debug("HTTP API Message received") + + header["content-type"] = "application/json" + + // echo the request in the response + let data = try JSONEncoder().encode(event) + let response = String(data: data, encoding: .utf8) + + // if you want control on the status code and headers, return an APIGatewayV2Response + // otherwise, just return any Encodable struct, the runtime will wrap it for you + return FunctionURLResponse(statusCode: .ok, headers: header, body: response) + + } catch { + // should never happen as the decoding was made by the runtime + // when the input event is malformed, this function is not even called + header["content-type"] = "text/plain" + return FunctionURLResponse( + statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") + + } + } +} diff --git a/Package.swift b/Package.swift index 1b47e1d0..ac2a605a 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,10 @@ let package = Package( .library(name: "AWSLambdaRuntimeCore", targets: ["AWSLambdaRuntimeCore"]), // plugin to package the lambda, creating an archive that can be uploaded to AWS .plugin(name: "AWSLambdaPackager", targets: ["AWSLambdaPackager"]), + // plugin to deploy the lambda, relies on AWS SAM command line + .plugin(name: "AWSLambdaDeployer", targets: ["AWSLambdaDeployer"]), + // Shared Library to generate a SAM deployment descriptor + .library(name: "AWSLambdaDeploymentDescriptor", type: .dynamic, targets: ["AWSLambdaDeploymentDescriptor"]), // for testing only .library(name: "AWSLambdaTesting", targets: ["AWSLambdaTesting"]), ], @@ -47,6 +51,20 @@ let package = Package( ) ) ), + .target( + name: "AWSLambdaDeploymentDescriptor", + path: "Sources/AWSLambdaDeploymentDescriptor" + ), + .plugin( + name: "AWSLambdaDeployer", + capability: .command( + intent: .custom( + verb: "deploy", + description: "Deploy the Lambda ZIP created by the archive plugin. Generates SAM-compliant deployment files based on deployment struct passed by the developer and invoke the SAM command." + ) +// permissions: [.writeToPackageDirectory(reason: "This plugin generates a SAM template to describe your deployment")] + ) + ), .testTarget(name: "AWSLambdaRuntimeCoreTests", dependencies: [ .byName(name: "AWSLambdaRuntimeCore"), .product(name: "NIOTestUtils", package: "swift-nio"), @@ -56,6 +74,9 @@ let package = Package( .byName(name: "AWSLambdaRuntimeCore"), .byName(name: "AWSLambdaRuntime"), ]), + .testTarget(name: "AWSLambdaDeploymentDescriptorTests", dependencies: [ + .byName(name: "AWSLambdaDeploymentDescriptor"), + ]), // testing helper .target(name: "AWSLambdaTesting", dependencies: [ .byName(name: "AWSLambdaRuntime"), diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index a4559656..30b4a33f 100644 --- a/Package@swift-5.7.swift +++ b/Package@swift-5.7.swift @@ -49,6 +49,20 @@ let package = Package( ) ) ), + .target( + name: "AWSLambdaDeploymentDescriptor", + path: "Sources/AWSLambdaDeploymentDescriptor" + ), + .plugin( + name: "AWSLambdaDeployer", + capability: .command( + intent: .custom( + verb: "deploy", + description: "Deploy the Lambda ZIP created by the archive plugin. Generates SAM-compliant deployment files based on deployment struct passed by the developer and invoke the SAM command." + ) +// permissions: [.writeToPackageDirectory(reason: "This plugin generates a SAM template to describe your deployment")] + ) + ), .testTarget(name: "AWSLambdaRuntimeCoreTests", dependencies: [ .byName(name: "AWSLambdaRuntimeCore"), .product(name: "NIOTestUtils", package: "swift-nio"), @@ -58,6 +72,9 @@ let package = Package( .byName(name: "AWSLambdaRuntimeCore"), .byName(name: "AWSLambdaRuntime"), ]), + .testTarget(name: "AWSLambdaDeploymentDescriptorTests", dependencies: [ + .byName(name: "AWSLambdaDeploymentDescriptor"), + ]), // testing helper .target(name: "AWSLambdaTesting", dependencies: [ .byName(name: "AWSLambdaRuntime"), diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift index a4559656..30b4a33f 100644 --- a/Package@swift-5.8.swift +++ b/Package@swift-5.8.swift @@ -49,6 +49,20 @@ let package = Package( ) ) ), + .target( + name: "AWSLambdaDeploymentDescriptor", + path: "Sources/AWSLambdaDeploymentDescriptor" + ), + .plugin( + name: "AWSLambdaDeployer", + capability: .command( + intent: .custom( + verb: "deploy", + description: "Deploy the Lambda ZIP created by the archive plugin. Generates SAM-compliant deployment files based on deployment struct passed by the developer and invoke the SAM command." + ) +// permissions: [.writeToPackageDirectory(reason: "This plugin generates a SAM template to describe your deployment")] + ) + ), .testTarget(name: "AWSLambdaRuntimeCoreTests", dependencies: [ .byName(name: "AWSLambdaRuntimeCore"), .product(name: "NIOTestUtils", package: "swift-nio"), @@ -58,6 +72,9 @@ let package = Package( .byName(name: "AWSLambdaRuntimeCore"), .byName(name: "AWSLambdaRuntime"), ]), + .testTarget(name: "AWSLambdaDeploymentDescriptorTests", dependencies: [ + .byName(name: "AWSLambdaDeploymentDescriptor"), + ]), // testing helper .target(name: "AWSLambdaTesting", dependencies: [ .byName(name: "AWSLambdaRuntime"), diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift new file mode 100644 index 00000000..8ec8f52b --- /dev/null +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -0,0 +1,590 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import Dispatch +import Foundation +import PackagePlugin + +@main +struct AWSLambdaDeployer: CommandPlugin { + func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { + + let configuration = try Configuration(context: context, arguments: arguments) + if configuration.help { + displayHelpMessage() + return + } + + // gather file paths + let samDeploymentDescriptorFilePath = "\(context.package.directory)/template.yaml" + + let swiftExecutablePath = try self.findExecutable(context: context, + executableName: "swift", + helpMessage: "Is Swift or Xcode installed? (https://www.swift.org/getting-started)", + verboseLogging: configuration.verboseLogging) + + let samExecutablePath = try self.findExecutable(context: context, + executableName: "sam", + helpMessage: "SAM command line is required. (brew tap aws/tap && brew install aws-sam-cli)", + verboseLogging: configuration.verboseLogging) + + let shellExecutablePath = try self.findExecutable(context: context, + executableName: "sh", + helpMessage: "The default shell (/bin/sh) is required to run this plugin", + verboseLogging: configuration.verboseLogging) + + let awsRegion = try self.getDefaultAWSRegion(context: context, + regionFromCommandLine: configuration.region, + verboseLogging: configuration.verboseLogging) + + // build the shared lib to compile the deployment descriptor + try self.compileSharedLibrary(projectDirectory: context.package.directory, + buildConfiguration: configuration.buildConfiguration, + swiftExecutable: swiftExecutablePath, + verboseLogging: configuration.verboseLogging) + + // generate the deployment descriptor + try self.generateDeploymentDescriptor(projectDirectory: context.package.directory, + buildConfiguration: configuration.buildConfiguration, + swiftExecutable: swiftExecutablePath, + shellExecutable: shellExecutablePath, + samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, + archivePath: configuration.archiveDirectory, + force: configuration.force, + verboseLogging: configuration.verboseLogging) + + + // check if there is a samconfig.toml file. + // when there is no file, generate one with default values and values collected from params + try self.checkOrCreateSAMConfigFile(projetDirectory: context.package.directory, + buildConfiguration: configuration.buildConfiguration, + region: awsRegion, + stackName: configuration.stackName, + force: configuration.force, + verboseLogging: configuration.verboseLogging) + + // validate the template + try self.validate(samExecutablePath: samExecutablePath, + samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, + verboseLogging: configuration.verboseLogging) + + + // deploy the functions + if !configuration.noDeploy { + try self.deploy(samExecutablePath: samExecutablePath, + buildConfiguration: configuration.buildConfiguration, + verboseLogging: configuration.verboseLogging) + } + + // list endpoints + if !configuration.noList { + let output = try self.listEndpoints(samExecutablePath: samExecutablePath, + samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, + stackName : configuration.stackName, + verboseLogging: configuration.verboseLogging) + print(output) + } + } + + private func compileSharedLibrary(projectDirectory: Path, + buildConfiguration: PackageManager.BuildConfiguration, + swiftExecutable: Path, + verboseLogging: Bool) throws { + print("-------------------------------------------------------------------------") + print("Compile shared library") + print("-------------------------------------------------------------------------") + + let cmd = [ "swift", "build", + "-c", buildConfiguration.rawValue, + "--product", "AWSLambdaDeploymentDescriptor"] + + try Utils.execute(executable: swiftExecutable, + arguments: Array(cmd.dropFirst()), + customWorkingDirectory: projectDirectory, + logLevel: verboseLogging ? .debug : .silent) + + } + + private func generateDeploymentDescriptor(projectDirectory: Path, + buildConfiguration: PackageManager.BuildConfiguration, + swiftExecutable: Path, + shellExecutable: Path, + samDeploymentDescriptorFilePath: String, + archivePath: String?, + force: Bool, + verboseLogging: Bool) throws { + print("-------------------------------------------------------------------------") + print("Generating SAM deployment descriptor") + print("-------------------------------------------------------------------------") + + // + // Build and run the Deploy.swift package description + // this generates the SAM deployment descriptor + // + let deploymentDescriptorFileName = "Deploy.swift" + let deploymentDescriptorFilePath = "\(projectDirectory)/\(deploymentDescriptorFileName)" + let sharedLibraryName = "AWSLambdaDeploymentDescriptor" // provided by the swift lambda runtime + + // Check if Deploy.swift exists. Stop when it does not exist. + guard FileManager.default.fileExists(atPath: deploymentDescriptorFilePath) else { + print("`Deploy.Swift` file not found in directory \(projectDirectory)") + throw DeployerPluginError.deployswiftDoesNotExist + } + + do { + var cmd = [ + "\"\(swiftExecutable.string)\"", + "-L \(projectDirectory)/.build/\(buildConfiguration)/", + "-I \(projectDirectory)/.build/\(buildConfiguration)/", + "-l\(sharedLibraryName)", + "\"\(deploymentDescriptorFilePath)\"" + ] + if let archive = archivePath { + cmd = cmd + ["--archive-path", archive] + } + let helperCmd = cmd.joined(separator: " \\\n") + + if verboseLogging { + print("-------------------------------------------------------------------------") + print("Swift compile and run Deploy.swift") + print("-------------------------------------------------------------------------") + print("Swift command:\n\n\(helperCmd)\n") + } + + // create and execute a plugin helper to run the "swift" command + let helperFilePath = "\(FileManager.default.temporaryDirectory.path)/compile.sh" + FileManager.default.createFile(atPath: helperFilePath, + contents: helperCmd.data(using: .utf8), + attributes: [.posixPermissions: 0o755]) + defer { try? FileManager.default.removeItem(atPath: helperFilePath) } + + // running the swift command directly from the plugin does not work 🤷‍♂️ + // the below launches a bash shell script that will launch the `swift` command + let samDeploymentDescriptor = try Utils.execute( + executable: shellExecutable, + arguments: ["-c", helperFilePath], + customWorkingDirectory: projectDirectory, + logLevel: verboseLogging ? .debug : .silent) + // let samDeploymentDescriptor = try Utils.execute( + // executable: swiftExecutable, + // arguments: Array(cmd.dropFirst()), + // customWorkingDirectory: projectDirectory, + // logLevel: verboseLogging ? .debug : .silent) + + // write the generated SAM deployment descriptor to disk + if FileManager.default.fileExists(atPath: samDeploymentDescriptorFilePath) && !force { + + print("SAM deployment descriptor already exists at") + print("\(samDeploymentDescriptorFilePath)") + print("use --force option to overwrite it.") + + } else { + + FileManager.default.createFile(atPath: samDeploymentDescriptorFilePath, + contents: samDeploymentDescriptor.data(using: .utf8)) + verboseLogging ? print("Writing file at \(samDeploymentDescriptorFilePath)") : nil + } + + } catch let error as DeployerPluginError { + print("Error while compiling Deploy.swift") + print(error) + print("Run the deploy plugin again with --verbose argument to receive more details.") + throw DeployerPluginError.error(error) + } catch { + print("Unexpected error : \(error)") + throw DeployerPluginError.error(error) + } + + } + + private func findExecutable(context: PluginContext, + executableName: String, + helpMessage: String, + verboseLogging: Bool) throws -> Path { + + guard let executable = try? context.tool(named: executableName) else { + print("Can not find `\(executableName)` executable.") + print(helpMessage) + throw DeployerPluginError.toolNotFound(executableName) + } + + if verboseLogging { + print("-------------------------------------------------------------------------") + print("\(executableName) executable : \(executable.path)") + print("-------------------------------------------------------------------------") + } + return executable.path + } + + private func validate(samExecutablePath: Path, + samDeploymentDescriptorFilePath: String, + verboseLogging: Bool) throws { + + print("-------------------------------------------------------------------------") + print("Validating SAM deployment descriptor") + print("-------------------------------------------------------------------------") + + do { + try Utils.execute( + executable: samExecutablePath, + arguments: ["validate", + "-t", samDeploymentDescriptorFilePath, + "--lint"], + logLevel: verboseLogging ? .debug : .silent) + + } catch let error as DeployerPluginError { + print("Error while validating the SAM template.") + print(error) + print("Run the deploy plugin again with --verbose argument to receive more details.") + throw DeployerPluginError.error(error) + } catch { + print("Unexpected error : \(error)") + throw DeployerPluginError.error(error) + } + } + + private func checkOrCreateSAMConfigFile(projetDirectory: Path, + buildConfiguration: PackageManager.BuildConfiguration, + region: String, + stackName: String, + force: Bool, + verboseLogging: Bool) throws { + + let samConfigFilePath = "\(projetDirectory)/samconfig.toml" // the default value for SAM + let samConfigTemplate = """ +version = 0.1 +[\(buildConfiguration)] +[\(buildConfiguration).deploy] +[\(buildConfiguration).deploy.parameters] +stack_name = "\(stackName)" +region = "\(region)" +capabilities = "CAPABILITY_IAM" +image_repositories = [] +""" + if FileManager.default.fileExists(atPath: samConfigFilePath) && !force { + + print("SAM configuration file already exists at") + print("\(samConfigFilePath)") + print("use --force option to overwrite it.") + + } else { + + // when SAM config does not exist, create it, it will allow function developers to customize and reuse it + FileManager.default.createFile(atPath: samConfigFilePath, + contents: samConfigTemplate.data(using: .utf8)) + verboseLogging ? print("Writing file at \(samConfigFilePath)") : nil + + } + } + + private func deploy(samExecutablePath: Path, + buildConfiguration: PackageManager.BuildConfiguration, + verboseLogging: Bool) throws { + + print("-------------------------------------------------------------------------") + print("Deploying AWS Lambda function") + print("-------------------------------------------------------------------------") + do { + + try Utils.execute( + executable: samExecutablePath, + arguments: ["deploy", + "--config-env", buildConfiguration.rawValue, + "--resolve-s3"], + logLevel: verboseLogging ? .debug : .silent) + } catch let error as DeployerPluginError { + print("Error while deploying the SAM template.") + print(error) + print("Run the deploy plugin again with --verbose argument to receive more details.") + throw DeployerPluginError.error(error) + } catch let error as Utils.ProcessError { + if case .processFailed(_, let errorCode, let output) = error { + if errorCode == 1 && output.contains("Error: No changes to deploy.") { + print("There is no changes to deploy.") + } else { + print("ProcessError : \(error)") + throw DeployerPluginError.error(error) + } + } + } catch { + print("Unexpected error : \(error)") + throw DeployerPluginError.error(error) + } + } + + private func listEndpoints(samExecutablePath: Path, + samDeploymentDescriptorFilePath: String, + stackName: String, + verboseLogging: Bool) throws -> String { + + print("-------------------------------------------------------------------------") + print("Listing AWS endpoints") + print("-------------------------------------------------------------------------") + do { + + return try Utils.execute( + executable: samExecutablePath, + arguments: ["list", "endpoints", + "-t", samDeploymentDescriptorFilePath, + "--stack-name", stackName, + "--output", "json"], + logLevel: verboseLogging ? .debug : .silent) + } catch { + print("Unexpected error : \(error)") + throw DeployerPluginError.error(error) + } + } + + + /// provides a region name where to deploy + /// first check for the region provided as a command line param to the plugin + /// second check AWS_DEFAULT_REGION + /// third check [default] profile from AWS CLI (when AWS CLI is installed) + private func getDefaultAWSRegion(context: PluginContext, + regionFromCommandLine: String?, + verboseLogging: Bool) throws -> String { + + let helpMsg = """ + Search order : 1. [--region] plugin parameter, + 2. AWS_DEFAULT_REGION environment variable, + 3. [default] profile from AWS CLI (~/.aws/config) +""" + + // first check the --region plugin command line + var result: String? = regionFromCommandLine + + guard result == nil else { + print("AWS Region : \(result!) (from command line)") + return result! + } + + // second check the environment variable + result = ProcessInfo.processInfo.environment["AWS_DEFAULT_REGION"] + if result != nil && result!.isEmpty { result = nil } + + guard result == nil else { + print("AWS Region : \(result!) (from environment variable)") + return result! + } + + // third, check from AWS CLI configuration when it is available + // aws cli is optional. It is used as last resort to identify the default AWS Region + if let awsCLIPath = try? self.findExecutable(context: context, + executableName: "aws", + helpMessage: "aws command line is used to find default AWS region. (brew install awscli)", + verboseLogging: verboseLogging) { + + let userDir = FileManager.default.homeDirectoryForCurrentUser.path + if FileManager.default.fileExists(atPath: "\(userDir)/.aws/config") { + // aws --profile default configure get region + do { + result = try Utils.execute( + executable: awsCLIPath, + arguments: ["--profile", "default", + "configure", + "get", "region"], + logLevel: verboseLogging ? .debug : .silent) + + result?.removeLast() // remove trailing newline char + } catch { + print("Unexpected error : \(error)") + throw DeployerPluginError.error(error) + } + + guard result == nil else { + print("AWS Region : \(result!) (from AWS CLI configuration)") + return result! + } + } else { + print("AWS CLI is not configured. Type `aws configure` to create a profile.") + } + } + + throw DeployerPluginError.noRegionFound(helpMsg) + } + + private func displayHelpMessage() { + print(""" +OVERVIEW: A swift plugin to deploy your Lambda function on your AWS account. + +REQUIREMENTS: To use this plugin, you must have an AWS account and have `sam` installed. + You can install sam with the following command: + (brew tap aws/tap && brew install aws-sam-cli) + +USAGE: swift package --disable-sandbox deploy [--help] [--verbose] + [--archive-path ] + [--configuration ] + [--force] [--nodeploy] [--nolist] + [--region ] + [--stack-name ] + +OPTIONS: + --verbose Produce verbose output for debugging. + --archive-path + The path where the archive plugin created the ZIP archive. + Must be aligned with the value passed to archive --output-path plugin. + (default: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager) + --configuration + Build for a specific configuration. + Must be aligned with what was used to build and package. + Valid values: [ debug, release ] (default: release) + --force Overwrites existing SAM deployment descriptor. + --nodeploy Generates the YAML deployment descriptor, but do not deploy. + --nolist Do not list endpoints. + --stack-name + The name of the CloudFormation stack when deploying. + (default: the project name) + --region The AWS region to deploy to. + (default: the region of AWS CLI's default profile) + --help Show help information. +""") + } +} + +private struct Configuration: CustomStringConvertible { + public let buildConfiguration: PackageManager.BuildConfiguration + public let help: Bool + public let noDeploy: Bool + public let noList: Bool + public let force: Bool + public let verboseLogging: Bool + public let archiveDirectory: String? + public let stackName: String + public let region: String? + + private let context: PluginContext + + public init( + context: PluginContext, + arguments: [String] + ) throws { + + self.context = context // keep a reference for self.description + + // extract command line arguments + var argumentExtractor = ArgumentExtractor(arguments) + let nodeployArgument = argumentExtractor.extractFlag(named: "nodeploy") > 0 + let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 + let noListArgument = argumentExtractor.extractFlag(named: "nolist") > 0 + let forceArgument = argumentExtractor.extractFlag(named: "force") > 0 + let configurationArgument = argumentExtractor.extractOption(named: "configuration") + let archiveDirectoryArgument = argumentExtractor.extractOption(named: "archive-path") + let stackNameArgument = argumentExtractor.extractOption(named: "stackname") + let regionArgument = argumentExtractor.extractOption(named: "region") + let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 + + // help required ? + self.help = helpArgument + + // force overwrite the SAM deployment descriptor when it already exists + self.force = forceArgument + + // define deployment option + self.noDeploy = nodeployArgument + + // define control on list endpoints after a deployment + self.noList = noListArgument + + // define logging verbosity + self.verboseLogging = verboseArgument + + // define build configuration, defaults to debug + if let buildConfigurationName = configurationArgument.first { + guard + let buildConfiguration = PackageManager.BuildConfiguration(rawValue: buildConfigurationName) + else { + throw DeployerPluginError.invalidArgument( + "invalid build configuration named '\(buildConfigurationName)'") + } + self.buildConfiguration = buildConfiguration + } else { + self.buildConfiguration = .release + } + + // use a default archive directory when none are given + if let archiveDirectory = archiveDirectoryArgument.first { + self.archiveDirectory = archiveDirectory + + // check if archive directory exists + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: archiveDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { + throw DeployerPluginError.invalidArgument( + "invalid archive directory: \(archiveDirectory)\nthe directory does not exists") + } + } else { + self.archiveDirectory = nil + } + + // infer or consume stack name + if let stackName = stackNameArgument.first { + self.stackName = stackName + } else { + self.stackName = context.package.displayName + } + + if let region = regionArgument.first { + self.region = region + } else { + self.region = nil + } + + if self.verboseLogging { + print("-------------------------------------------------------------------------") + print("configuration") + print("-------------------------------------------------------------------------") + print(self) + } + } + + var description: String { + """ + { + verbose: \(self.verboseLogging) + force: \(self.force) + noDeploy: \(self.noDeploy) + noList: \(self.noList) + buildConfiguration: \(self.buildConfiguration) + archiveDirectory: \(self.archiveDirectory ?? "none provided on command line") + stackName: \(self.stackName) + region: \(self.region ?? "none provided on command line") + Plugin directory: \(self.context.pluginWorkDirectory) + Project directory: \(self.context.package.directory) + } + """ + } +} + +private enum DeployerPluginError: Error, CustomStringConvertible { + case invalidArgument(String) + case toolNotFound(String) + case deployswiftDoesNotExist + case noRegionFound(String) + case error(Error) + + var description: String { + switch self { + case .invalidArgument(let description): + return description + case .toolNotFound(let tool): + return tool + case .deployswiftDoesNotExist: + return "Deploy.swift does not exist" + case .noRegionFound(let msg): + return "Can not find an AWS Region to deploy.\n\(msg)" + case .error(let rootCause): + return "Error caused by:\n\(rootCause)" + } + } +} + diff --git a/Plugins/AWSLambdaDeployer/PluginUtils.swift b/Plugins/AWSLambdaDeployer/PluginUtils.swift new file mode 120000 index 00000000..97067e31 --- /dev/null +++ b/Plugins/AWSLambdaDeployer/PluginUtils.swift @@ -0,0 +1 @@ +../AWSLambdaPackager/PluginUtils.swift \ No newline at end of file diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md new file mode 100644 index 00000000..733a0870 --- /dev/null +++ b/Plugins/AWSLambdaDeployer/README.md @@ -0,0 +1,201 @@ +This PR shows proof-of-concept code to add a deployer plugin, in addition to the existing archiver plugin. The deployer plugin generates a SAM deployment descriptor and calls the SAM command line to deploy the lambda function and it's dependencies. + +## Motivation + +The existing `archive` plugin generates a ZIP to be deployed on AWS. While it removes undifferentiated heavy lifting to compile and package Swift code into a Lambda function package, it does not help Swift developers to deploy the Lambda function to AWS, nor define how to invoke this function from other AWS services. Deploying requires knowledge about AWS, and deployment tools such as the AWS CLI, the CDK, the SAM CLI, or the AWS console. + +Furthermore, most developers will deploy a Lambda function together with some front end infrastructure allowing to invoke the Lambda function. Most common invocation methods are through an HTTP REST API (provided by API Gateway) or processing messages from queues (SQS). This means that, in addition of the deployment of the lambda function itself, the Lambda function developer must create, configure, and link to the Lambda function an API Gateway or a SQS queue. + +SAM is an open source command line tool that allows Lambda function developers to easily express the function dependencies on other AWS services and deploy the function and its dependencies with an easy-to-use command lien tool. It allows developers to describe the function runtime environment and the additional resources that will trigger the lambda function in a simple YAML file. SAM CLI allows to validate the YAML file and to deploy the infrastructure into the AWS cloud. + +It also allows for local testing, by providing a Local Lambda runtime environment and a local API Gateway mock in a docker container. + +The `deploy` plugin leverages SAM to create an end-to-end infrastructure and to deploy it on AWS. It relies on configuration provided by the Swift lambda function developer to know how to expose the Lambda function to the external world. Right now, it supports a subset of HTTP API Gateway v2 and SQS queues. + +The Lambda function developer describes the API gateway or SQS queue using a Swift-based domain specific language (DSL) by writing a `Deploy.swift` file. The plugin transform the `Deploy.swift` data structure into a YAML SAM template. It then calls the SAM CLI to validate and to deploy the template. + +## Modifications: + +I added two targets to `Package.swift` : + +- `AWSLambdaDeployer` is the plugin itself. I followed the same structure and code as the `archive` plugin. Common code between the two plugins has been isolated in a shared `PluginUtils.swift` file. Because of [a limitation in the current Swift package systems for plugins](https://forums.swift.org/t/difficulty-sharing-code-between-swift-package-manager-plugins/61690/11), I symlinked the file from one plugin directory to the other. + +- `AWSLambdaDeploymentDescriptor` is a shared library that contains the data structures definition to describe and to generate a YAML SAM deployment file. It models SAM resources such as a Lambda functions and its event sources : HTTP API and SQS queue. It contains the logic to generate the SAM deployment descriptor, using minimum information provided by the Swift lambda function developer. At the moment it provides a very minimal subset of the supported SAM configuration. I am ready to invest more time to cover more resource types and more properties if this proposal is accepted. + +I also added a new example project : `SAM`. It contains two Lambda functions, one invoked through HTTP API, and one invoked through SQS. It also defines shared resources such as SQS Queue and a DynamoDB Table. It provides a `Deploy.swift` example to describe the required HTTP API and SQS code and to allow `AWSLambdaDeploymentDescriptor` to generate the SAM deployment descriptor. The project also contains unit testing for the two Lambda functions. + +## Result: + +As a Swift function developer, here is the workflow to use the new `deploy` plugin. + +1. I create a Lambda function as usual. I use the Lambda Events library to write my code. Here is an example (nothing changed - this is just to provide a starting point) : + +```swift +import AWSLambdaEvents +import AWSLambdaRuntime +import Foundation + +@main +struct HttpApiLambda: SimpleLambdaHandler { + typealias Event = APIGatewayV2Request + typealias Output = APIGatewayV2Response + + init() {} + init(context: LambdaInitializationContext) async throws { + context.logger.info( + "Log Level env var : \(ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "info" )") + } + + func handle(_ event: Event, context: AWSLambdaRuntimeCore.LambdaContext) async throws -> Output { + + var header = HTTPHeaders() + do { + context.logger.debug("HTTP API Message received") + + header["content-type"] = "application/json" + + // echo the request in the response + let data = try JSONEncoder().encode(event) + let response = String(data: data, encoding: .utf8) + + return Output(statusCode: .accepted, headers: header, body: response) + + } catch { + header["content-type"] = "text/plain" + return Output(statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") + } + } +} +``` + +2. I create a `Deploy.swift` file to describe the SAM deployment descriptor. Most of the deployment descriptor will be generated automatically from context, I just have to provide the specifics for my code. In this example, I want the Lambda function to be invoked from an HTTP REST API. I want the code to be invoked on `GET` HTTP method for the `/test` path. I also want to position the LOG_LEVEL environment variable to `debug`. + +I add the new `Deploy.swift` file at the top of my project. Here is a simple deployment file. A more complex one is provided in the `Examples/SAM` sample project. + +```swift +import AWSLambdaDeploymentDescriptor + +DeploymentDescriptor { + // a mandatory description + "Description of this deployment descriptor" + + // the lambda function + Function(name: "HttpApiLambda") { + EventSources { + HttpApi(method: .GET, path: "/test") // example of an API for a specific HTTP verb and path + } + // optional environment variables + EnvironmentVariables { + [ "NAME1": "VALUE1" ] + } + } +} +``` + +3. I invoke the archive plugin and the deploy plugin from the command line. + +```bash + +# first create the zip file +swift package --disable-sandbox archive + +# second deploy it with an HTTP API Gateway +swift package --disable-sandbox deploy +``` + +Similarly to the archiver plugin, the deployer plugin must escape the sandbox because the SAM CLI makes network calls to AWS API (IAM and CloudFormation) to validate and to deploy the template. + +4. (optionally) Swift lambda function developer may also use SAM to test the code locally. + +```bash +sam local invoke -t template.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda +``` + +## Command Line Options + +The deployer plugin accepts multiple options on the command line. + +```bash +swift package plugin deploy --help + +OVERVIEW: A swift plugin to deploy your Lambda function on your AWS account. + +REQUIREMENTS: To use this plugin, you must have an AWS account and have `sam` installed. + You can install sam with the following command: + (brew tap aws/tap && brew install aws-sam-cli) + +USAGE: swift package --disable-sandbox deploy [--help] [--verbose] + [--archive-path ] + [--configuration ] + [--force] [--nodeploy] [--nolist] + [--region ] + [--stack-name ] + +OPTIONS: + --verbose Produce verbose output for debugging. + --archive-path + The path where the archive plugin created the ZIP archive. + Must be aligned with the value passed to archive --output-path plugin. + (default: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager) + --configuration + Build for a specific configuration. + Must be aligned with what was used to build and package. + Valid values: [ debug, release ] (default: debug) + --force Overwrites existing SAM deployment descriptor. + --nodeploy Generates the YAML deployment descriptor, but do not deploy. + --nolist Do not list endpoints. + --stack-name + The name of the CloudFormation stack when deploying. + (default: the project name) + --region The AWS region to deploy to. + (default: the region of AWS CLI's default profile) + --help Show help information. +``` + +### Design Decisions + +#### SAM + +SAM is already broadly adopted, well maintained and documented. It does the job. I think it is easier to ask Swift Lambda function developers to install SAM (it is just two `brew` commands) rather than having this project investing in its own mechanism to describe a deployment and to generate the CloudFormation or CDK code to deploy the Lambda function and its dependencies. In the future, we might imagine a multi-framework solution where the plugin could generate code for SAM, or CDK, or Serverless etc ... + +#### Deploy.swift DSL + +Swift Lambda function developers must be able to describe the additional infrastructure services required to deploy their functions: a SQS queue, an HTTP API etc. + +I assume the typical Lambda function developer knows the Swift programming language, but not the AWS-specific DSL (such as SAM or CloudFormation) required to describe and deploy the project dependencies. I chose to ask the Lambda function developer to describe its deployment with a Swift DSL in a top-level `Deploy.swift` file. The `deploy` plugin dynamically compiles this file to generate the SAM YAML deployment descriptor. + +The source code to implement this approach is in the `AWSLambdaDeploymentDescriptor` library. + +This is a strong design decision and [a one-way door](https://shit.management/one-way-and-two-way-door-decisions/). It engages the maintainer of the project on the long term to implement and maintain (close) feature parity between SAM DSL and the Swift `AWSLambdaDeploymentDescriptor` library and DSL. + +One way to mitigate the maintenance work would be to generate the `AWSLambdaDeploymentDescriptor` library automatically, based on the [the SAM schema definition](https://github.com/aws/serverless-application-model/blob/develop/samtranslator/validator/sam_schema/schema.json). The core structs might be generated automatically and we would need to manually maintain only a couple of extensions providing syntactic sugar for Lambda function developers. This approach is similar to AWS SDKs code generation ([Soto](https://github.com/soto-project/soto-codegenerator) and the [AWS SDK for Swift](https://github.com/awslabs/aws-sdk-swift/tree/main/codegen)). This would require a significant one-time engineering effort however and I haven't had time to further explore this idea. + +**Alternatives Considered** + +The first approach I used to implement `Deploy.swift` was pure programmatic. Developers would have to define a data structure in the initializer of the `DeploymentDescriptor` struct. This approach was similar to current `Package.swift`. After initial review and discussions, @tomerd suggested to use a DSL approach instead as it is simpler to read and write, it requires less punctuation marks, etc. + +An alternative would be to not use a DSL approach to describe the deployment at all (i.e. remove `Deploy.swift` and the `AWSLambdaDeploymentDescriptor` from this PR). In this scenario, the `deploy` plugin would generate a minimum SAM deployment template with default configuration for the current Lambda functions in the build target. The plugin would accept command-line arguments for basic pre-configuration of dependant AWS services, such as `--httpApi` or `--sqs ` for example. The Swift Lambda function developer could leverage this SAM template to provide additional infrastructure or configuration elements as required. After having generated the initial SAM template, the `deploy` plugin will not overwrite the changes made by the developer. + +This approach removes the need to maintain feature parity between the SAM DSL and the `AWSLambdaDeploymentDescriptor` library. + +Please comment on this PR to share your feedback about the current design decisions and the proposed alternatives (or propose other alternatives :-) ) + +### What is missing + +If this proposal is accepted in its current format, Swift Lambda function developers would need a much larger coverage of the SAM template format. I will add support for resources and properties. We can also look at generating the Swift data structures automatically from the AWS-provided SAM schema definition (in JSON) + +### Future directions + +Here are a list of todo and thoughts for future implementations. + +- Both for the `deploy` and the `archive` plugin, it would be great to have a more granular permission mechanism allowing to escape the SPM plugin sandbox for selected network calls. SPM 5.8 should make this happen. + +- For HTTPApi, I believe the default SAM code and Lambda function examples must create Authenticated API by default. I believe our duty is to propose secured code by default and not encourage bad practices such as deploying open endpoints. But this approach will make the initial developer experience a bit more complex. + +- This project should add sample code to demonstrate how to use the Soto SDK or the AWS SDK for Swift. I suspect most Swift Lambda function will leverage other AWS services. + +- What about bootstrapping new projects? I would like to create a plugin or command line tool that would scaffold a new project, create the `Package.swift` file and the required project directory and files. We could imagine a CLI or SPM plugin that ask the developer a couple of questions, such as how she wants to trigger the Lambda function and generate the corresponding code. + +--- + +Happy to read your feedback and suggestions. Let's make the deployment of Swift Lambda functions easier for Swift developers. diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index 3a8c8b20..e03a389d 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -1,4 +1,4 @@ -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // // This source file is part of the SwiftAWSLambdaRuntime open source project // @@ -10,9 +10,8 @@ // // SPDX-License-Identifier: Apache-2.0 // -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// -import Dispatch import Foundation import PackagePlugin @@ -25,7 +24,7 @@ struct AWSLambdaPackager: CommandPlugin { func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { let configuration = try Configuration(context: context, arguments: arguments) guard !configuration.products.isEmpty else { - throw Errors.unknownProduct("no appropriate products found to package") + throw PackagerPluginErrors.unknownProduct("no appropriate products found to package") } if configuration.products.count > 1 && !configuration.explicitProducts { @@ -91,7 +90,7 @@ struct AWSLambdaPackager: CommandPlugin { if !disableDockerImageUpdate { // update the underlying docker image, if necessary print("updating \"\(baseImage)\" docker image") - try self.execute( + try Utils.execute( executable: dockerToolPath, arguments: ["pull", baseImage], logLevel: .output @@ -100,13 +99,13 @@ struct AWSLambdaPackager: CommandPlugin { // get the build output path let buildOutputPathCommand = "swift build -c \(buildConfiguration.rawValue) --show-bin-path" - let dockerBuildOutputPath = try self.execute( + let dockerBuildOutputPath = try Utils.execute( executable: dockerToolPath, arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildOutputPathCommand], logLevel: verboseLogging ? .debug : .silent ) guard let buildPathOutput = dockerBuildOutputPath.split(separator: "\n").last else { - throw Errors.failedParsingDockerOutput(dockerBuildOutputPath) + throw PackagerPluginErrors.failedParsingDockerOutput(dockerBuildOutputPath) } let buildOutputPath = Path(buildPathOutput.replacingOccurrences(of: "/workspace", with: packageDirectory.string)) @@ -121,13 +120,13 @@ struct AWSLambdaPackager: CommandPlugin { // just like Package.swift's examples assume ../.., we assume we are two levels below the root project let lastComponent = packageDirectory.lastComponent let beforeLastComponent = packageDirectory.removingLastComponent().lastComponent - try self.execute( + try Utils.execute( executable: dockerToolPath, arguments: ["run", "--rm", "--env", "LAMBDA_USE_LOCAL_DEPS=true", "-v", "\(packageDirectory.string)/../..:/workspace", "-w", "/workspace/\(beforeLastComponent)/\(lastComponent)", baseImage, "bash", "-cl", buildCommand], logLevel: verboseLogging ? .debug : .output ) } else { - try self.execute( + try Utils.execute( executable: dockerToolPath, arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], logLevel: verboseLogging ? .debug : .output @@ -136,7 +135,7 @@ struct AWSLambdaPackager: CommandPlugin { let productPath = buildOutputPath.appending(product.name) guard FileManager.default.fileExists(atPath: productPath.string) else { Diagnostics.error("expected '\(product.name)' binary at \"\(productPath.string)\"") - throw Errors.productExecutableNotFound(product.name) + throw PackagerPluginErrors.productExecutableNotFound(product.name) } builtProducts[.init(product)] = productPath } @@ -166,7 +165,7 @@ struct AWSLambdaPackager: CommandPlugin { parameters: parameters ) guard let artifact = result.executableArtifact(for: product) else { - throw Errors.productExecutableNotFound(product.name) + throw PackagerPluginErrors.productExecutableNotFound(product.name) } results[.init(product)] = artifact.path } @@ -209,7 +208,7 @@ struct AWSLambdaPackager: CommandPlugin { #endif // run the zip tool - try self.execute( + try Utils.execute( executable: zipToolPath, arguments: arguments, logLevel: verboseLogging ? .debug : .silent @@ -220,77 +219,6 @@ struct AWSLambdaPackager: CommandPlugin { return archives } - @discardableResult - private func execute( - executable: Path, - arguments: [String], - customWorkingDirectory: Path? = .none, - logLevel: ProcessLogLevel - ) throws -> String { - if logLevel >= .debug { - print("\(executable.string) \(arguments.joined(separator: " "))") - } - - var output = "" - let outputSync = DispatchGroup() - let outputQueue = DispatchQueue(label: "AWSLambdaPackager.output") - let outputHandler = { (data: Data?) in - dispatchPrecondition(condition: .onQueue(outputQueue)) - - outputSync.enter() - defer { outputSync.leave() } - - guard let _output = data.flatMap({ String(data: $0, encoding: .utf8)?.trimmingCharacters(in: CharacterSet(["\n"])) }), !_output.isEmpty else { - return - } - - output += _output + "\n" - - switch logLevel { - case .silent: - break - case .debug(let outputIndent), .output(let outputIndent): - print(String(repeating: " ", count: outputIndent), terminator: "") - print(_output) - fflush(stdout) - } - } - - let pipe = Pipe() - pipe.fileHandleForReading.readabilityHandler = { fileHandle in outputQueue.async { outputHandler(fileHandle.availableData) } } - - let process = Process() - process.standardOutput = pipe - process.standardError = pipe - process.executableURL = URL(fileURLWithPath: executable.string) - process.arguments = arguments - if let workingDirectory = customWorkingDirectory { - process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory.string) - } - process.terminationHandler = { _ in - outputQueue.async { - outputHandler(try? pipe.fileHandleForReading.readToEnd()) - } - } - - try process.run() - process.waitUntilExit() - - // wait for output to be full processed - outputSync.wait() - - if process.terminationStatus != 0 { - // print output on failure and if not already printed - if logLevel < .output { - print(output) - fflush(stdout) - } - throw Errors.processFailed([executable.string] + arguments, process.terminationStatus) - } - - return output - } - private func isAmazonLinux2() -> Bool { if let data = FileManager.default.contents(atPath: "/etc/system-release"), let release = String(data: data, encoding: .utf8) { return release.hasPrefix("Amazon Linux release 2") @@ -327,7 +255,7 @@ private struct Configuration: CustomStringConvertible { if let outputPath = outputPathArgument.first { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: outputPath, isDirectory: &isDirectory), isDirectory.boolValue else { - throw Errors.invalidArgument("invalid output directory '\(outputPath)'") + throw PackagerPluginErrors.invalidArgument("invalid output directory '\(outputPath)'") } self.outputDirectory = Path(outputPath) } else { @@ -339,7 +267,7 @@ private struct Configuration: CustomStringConvertible { let products = try context.package.products(named: productsArgument) for product in products { guard product is ExecutableProduct else { - throw Errors.invalidArgument("product named '\(product.name)' is not an executable product") + throw PackagerPluginErrors.invalidArgument("product named '\(product.name)' is not an executable product") } } self.products = products @@ -350,7 +278,7 @@ private struct Configuration: CustomStringConvertible { if let buildConfigurationName = configurationArgument.first { guard let buildConfiguration = PackageManager.BuildConfiguration(rawValue: buildConfigurationName) else { - throw Errors.invalidArgument("invalid build configuration named '\(buildConfigurationName)'") + throw PackagerPluginErrors.invalidArgument("invalid build configuration named '\(buildConfigurationName)'") } self.buildConfiguration = buildConfiguration } else { @@ -358,7 +286,7 @@ private struct Configuration: CustomStringConvertible { } guard !(!swiftVersionArgument.isEmpty && !baseDockerImageArgument.isEmpty) else { - throw Errors.invalidArgument("--swift-version and --base-docker-image are mutually exclusive") + throw PackagerPluginErrors.invalidArgument("--swift-version and --base-docker-image are mutually exclusive") } let swiftVersion = swiftVersionArgument.first ?? .none // undefined version will yield the latest docker image @@ -387,43 +315,13 @@ private struct Configuration: CustomStringConvertible { } } -private enum ProcessLogLevel: Comparable { - case silent - case output(outputIndent: Int) - case debug(outputIndent: Int) - - var naturalOrder: Int { - switch self { - case .silent: - return 0 - case .output: - return 1 - case .debug: - return 2 - } - } - - static var output: Self { - .output(outputIndent: 2) - } - - static var debug: Self { - .debug(outputIndent: 2) - } - - static func < (lhs: ProcessLogLevel, rhs: ProcessLogLevel) -> Bool { - lhs.naturalOrder < rhs.naturalOrder - } -} - -private enum Errors: Error, CustomStringConvertible { +private enum PackagerPluginErrors: Error, CustomStringConvertible { case invalidArgument(String) case unsupportedPlatform(String) case unknownProduct(String) case productExecutableNotFound(String) case failedWritingDockerfile case failedParsingDockerOutput(String) - case processFailed([String], Int32) var description: String { switch self { @@ -439,8 +337,6 @@ private enum Errors: Error, CustomStringConvertible { return "failed writing dockerfile" case .failedParsingDockerOutput(let output): return "failed parsing docker output: '\(output)'" - case .processFailed(let arguments, let code): - return "\(arguments.joined(separator: " ")) failed with code \(code)" } } } diff --git a/Plugins/AWSLambdaPackager/PluginUtils.swift b/Plugins/AWSLambdaPackager/PluginUtils.swift new file mode 100644 index 00000000..b53addfd --- /dev/null +++ b/Plugins/AWSLambdaPackager/PluginUtils.swift @@ -0,0 +1,129 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import Dispatch +import Foundation +import PackagePlugin + +struct Utils { + @discardableResult + static func execute( + executable: Path, + arguments: [String], + customWorkingDirectory: Path? = .none, + logLevel: ProcessLogLevel + ) throws -> String { + if logLevel >= .debug { + print("\(executable.string) \(arguments.joined(separator: " "))") + } + + var output = "" + let outputSync = DispatchGroup() + let outputQueue = DispatchQueue(label: "AWSLambdaPackager.output") + let outputHandler = { (data: Data?) in + dispatchPrecondition(condition: .onQueue(outputQueue)) + + outputSync.enter() + defer { outputSync.leave() } + + guard let _output = data.flatMap({ String(data: $0, encoding: .utf8)?.trimmingCharacters(in: CharacterSet(["\n"])) }), !_output.isEmpty else { + return + } + + output += _output + "\n" + + switch logLevel { + case .silent: + break + case .debug(let outputIndent), .output(let outputIndent): + print(String(repeating: " ", count: outputIndent), terminator: "") + print(_output) + fflush(stdout) + } + } + + let pipe = Pipe() + pipe.fileHandleForReading.readabilityHandler = { fileHandle in outputQueue.async { outputHandler(fileHandle.availableData) } } + + let process = Process() + process.standardOutput = pipe + process.standardError = pipe + process.executableURL = URL(fileURLWithPath: executable.string) + process.arguments = arguments + if let workingDirectory = customWorkingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory.string) + } + process.terminationHandler = { _ in + outputQueue.async { + outputHandler(try? pipe.fileHandleForReading.readToEnd()) + } + } + + try process.run() + process.waitUntilExit() + + // wait for output to be full processed + outputSync.wait() + + if process.terminationStatus != 0 { + // print output on failure and if not already printed + if logLevel < .output { + print(output) + fflush(stdout) + } + throw ProcessError.processFailed([executable.string] + arguments, process.terminationStatus, output) + } + + return output + } + + enum ProcessError: Error, CustomStringConvertible { + case processFailed([String], Int32, String) + + var description: String { + switch self { + case .processFailed(let arguments, let code, _): + return "\(arguments.joined(separator: " ")) failed with code \(code)" + } + } + } + enum ProcessLogLevel: Comparable { + case silent + case output(outputIndent: Int) + case debug(outputIndent: Int) + + var naturalOrder: Int { + switch self { + case .silent: + return 0 + case .output: + return 1 + case .debug: + return 2 + } + } + + static var output: Self { + .output(outputIndent: 2) + } + + static var debug: Self { + .debug(outputIndent: 2) + } + + static func < (lhs: ProcessLogLevel, rhs: ProcessLogLevel) -> Bool { + lhs.naturalOrder < rhs.naturalOrder + } + } +} diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift new file mode 100644 index 00000000..16e6b5cc --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -0,0 +1,581 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import Foundation + +// maybe this file might be generated entirely or partially automatically from +// https://github.com/aws/serverless-application-model/blob/develop/samtranslator/validator/sam_schema/schema.json + +// a Swift definition of a SAM deployment descriptor. +// currently limited to the properties I needed for the examples. +// An immediate TODO if this code is accepted is to add more properties and more struct +public struct SAMDeploymentDescriptor: Encodable { + + let templateVersion: String = "2010-09-09" + let transform: String = "AWS::Serverless-2016-10-31" + let description: String + var resources: [String: Resource] = [:] + + public init( + description: String, + resources: [Resource] = [] + ) { + self.description = description + + // extract resources names for serialization + for res in resources { + self.resources[res.name] = res + } + } + + enum CodingKeys: String, CodingKey { + case templateVersion = "AWSTemplateFormatVersion" + case transform + case description + case resources + } +} + +public protocol SAMResource: Encodable, Equatable {} +public protocol SAMResourceType: Encodable, Equatable {} +public protocol SAMResourceProperties: Encodable {} + +public enum ResourceType: String, SAMResourceType { + case function = "AWS::Serverless::Function" + case queue = "AWS::SQS::Queue" + case table = "AWS::Serverless::SimpleTable" +} + +public enum EventSourceType: String, SAMResourceType { + case httpApi = "HttpApi" + case sqs = "SQS" +} + +// generic type to represent either a top-level resource or an event source +public struct Resource: SAMResource { + + let type: T + let properties: SAMResourceProperties? + let name: String + + public static func == (lhs: Resource, rhs: Resource) -> Bool { + lhs.type == rhs.type && lhs.name == rhs.name + } + + enum CodingKeys: CodingKey { + case type + case properties + } + + // this is to make the compiler happy : Resource now conforms to Encodable + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.type, forKey: .type) + if let properties = self.properties { + try container.encode(properties, forKey: .properties) + } + } +} + +// MARK: Lambda Function resource definition + +/*--------------------------------------------------------------------------------------- + Lambda Function + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html + -----------------------------------------------------------------------------------------*/ + +public struct ServerlessFunctionProperties: SAMResourceProperties { + + public enum Architectures: String, Encodable, CaseIterable { + case x64 = "x86_64" + case arm64 = "arm64" + + // the default value is the current architecture + public static func defaultArchitecture() -> Architectures { + #if arch(arm64) + return .arm64 + #else // I understand this #else will not always be true. Developers can overwrite the default in Deploy.swift + return .x64 + #endif + } + + // valid values for error and help message + public static func validValues() -> String { + return Architectures.allCases.map { $0.rawValue }.joined(separator: ", ") + } + } + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-ephemeralstorage.html + public struct EphemeralStorage: Encodable { + private let validValues = 512...10240 + let size: Int + init?(_ size: Int) { + if validValues.contains(size) { + self.size = size + } else { + return nil + } + } + enum CodingKeys: String, CodingKey { + case size = "Size" + } + } + + public struct EventInvokeConfiguration: Encodable { + public enum EventInvokeDestinationType: String, Encodable { + case sqs = "SQS" + case sns = "SNS" + case lambda = "Lambda" + case eventBridge = "EventBridge" + + public static func destinationType(from arn: Arn?) -> EventInvokeDestinationType? { + guard let service = arn?.service() else { + return nil + } + switch service.lowercased() { + case "sqs": + return .sqs + case "sns": + return .sns + case "lambda": + return .lambda + case "eventbridge": + return .eventBridge + default: + return nil + } + } + public static func destinationType(from resource: Resource?) + -> EventInvokeDestinationType? + { + guard let res = resource else { + return nil + } + switch res.type { + case .queue: + return .sqs + case .function: + return .lambda + default: + return nil + } + } + } + public struct EventInvokeDestination: Encodable { + let destination: Reference? + let type: EventInvokeDestinationType? + } + public struct EventInvokeDestinationConfiguration: Encodable { + let onSuccess: EventInvokeDestination? + let onFailure: EventInvokeDestination? + } + let destinationConfig: EventInvokeDestinationConfiguration? + let maximumEventAgeInSeconds: Int? + let maximumRetryAttempts: Int? + } + + //TODO: add support for reference to other resources of type elasticfilesystem or mountpoint + public struct FileSystemConfig: Encodable { + + // regex from + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-filesystemconfig.html + let validMountPathRegex = #"^/mnt/[a-zA-Z0-9-_.]+$"# + let validArnRegex = + #"arn:aws[a-zA-Z-]*:elasticfilesystem:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1}:\d{12}:access-point/fsap-[a-f0-9]{17}"# + let reference: Reference + let localMountPath: String + + public init?(arn: String, localMountPath: String) { + + guard arn.range(of: validArnRegex, options: .regularExpression) != nil, + localMountPath.range(of: validMountPathRegex, options: .regularExpression) != nil + else { + return nil + } + + self.reference = .arn(Arn(arn)!) + self.localMountPath = localMountPath + } + enum CodingKeys: String, CodingKey { + case reference = "Arn" + case localMountPath + } + } + + public struct URLConfig: Encodable { + public enum AuthType: String, Encodable { + case iam = "AWS_IAM" + case none = "None" + } + public enum InvokeMode: String, Encodable { + case responseStream = "RESPONSE_STREAM" + case buffered = "BUFFERED" + } + public struct Cors: Encodable { + let allowCredentials: Bool? + let allowHeaders: [String]? + let allowMethods: [String]? + let allowOrigins: [String]? + let exposeHeaders: [String]? + let maxAge: Int? + } + let authType: AuthType + let cors: Cors? + let invokeMode: InvokeMode? + } + + let architectures: [Architectures] + let handler: String + let runtime: String + let codeUri: String? + var autoPublishAlias: String? + var autoPublishAliasAllProperties: Bool? + var autoPublishCodeSha256: String? + var events: [String: Resource]? + var environment: SAMEnvironmentVariable? + var description: String? + var ephemeralStorage: EphemeralStorage? + var eventInvokeConfig: EventInvokeConfiguration? + var fileSystemConfigs: [FileSystemConfig]? + var functionUrlConfig: URLConfig? + + public init( + codeUri: String?, + architecture: Architectures, + eventSources: [Resource] = [], + environment: SAMEnvironmentVariable? = nil + ) { + + self.architectures = [architecture] + self.handler = "Provided" + self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 + self.codeUri = codeUri + self.environment = environment + + if !eventSources.isEmpty { + self.events = [:] + for es in eventSources { + self.events![es.name] = es + } + } + } +} + +/* + Environment: + Variables: + LOG_LEVEL: debug + */ +public struct SAMEnvironmentVariable: Encodable { + + public var variables: [String: SAMEnvironmentVariableValue] = [:] + public init() {} + public init(_ variables: [String: String]) { + for key in variables.keys { + self.variables[key] = .string(value: variables[key] ?? "") + } + } + public static var none: SAMEnvironmentVariable { return SAMEnvironmentVariable([:]) } + + public static func variable(_ name: String, _ value: String) -> SAMEnvironmentVariable { + return SAMEnvironmentVariable([name: value]) + } + public static func variable(_ variables: [String: String]) -> SAMEnvironmentVariable { + return SAMEnvironmentVariable(variables) + } + public static func variable(_ variables: [[String: String]]) -> SAMEnvironmentVariable { + + var mergedDictKeepCurrent: [String: String] = [:] + variables.forEach { dict in + // inspired by https://stackoverflow.com/a/43615143/663360 + mergedDictKeepCurrent = mergedDictKeepCurrent.merging(dict) { (current, _) in current } + } + + return SAMEnvironmentVariable(mergedDictKeepCurrent) + + } + public func isEmpty() -> Bool { return variables.count == 0 } + + public mutating func append(_ key: String, _ value: String) { + variables[key] = .string(value: value) + } + public mutating func append(_ key: String, _ value: [String: String]) { + variables[key] = .array(value: value) + } + public mutating func append(_ key: String, _ value: [String: [String]]) { + variables[key] = .dictionary(value: value) + } + public mutating func append(_ key: String, _ value: Resource) { + variables[key] = .array(value: ["Ref": value.name]) + } + + enum CodingKeys: CodingKey { + case variables + } + + public func encode(to encoder: Encoder) throws { + + guard !self.isEmpty() else { + return + } + + var container = encoder.container(keyedBy: CodingKeys.self) + var nestedContainer = container.nestedContainer(keyedBy: AnyStringKey.self, forKey: .variables) + + for key in variables.keys { + switch variables[key] { + case .string(let value): + try? nestedContainer.encode(value, forKey: AnyStringKey(key)) + case .array(let value): + try? nestedContainer.encode(value, forKey: AnyStringKey(key)) + case .dictionary(let value): + try? nestedContainer.encode(value, forKey: AnyStringKey(key)) + case .none: + break + } + } + } + + public enum SAMEnvironmentVariableValue { + // KEY: VALUE + case string(value: String) + + // KEY: + // Ref: VALUE + case array(value: [String: String]) + + // KEY: + // Fn::GetAtt: + // - VALUE1 + // - VALUE2 + case dictionary(value: [String: [String]]) + } +} + +internal struct AnyStringKey: CodingKey, Hashable, ExpressibleByStringLiteral { + var stringValue: String + init(stringValue: String) { self.stringValue = stringValue } + init(_ stringValue: String) { self.init(stringValue: stringValue) } + var intValue: Int? + init?(intValue: Int) { return nil } + init(stringLiteral value: String) { self.init(value) } +} + +// MARK: HTTP API Event definition +/*--------------------------------------------------------------------------------------- + HTTP API Event (API Gateway v2) + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-httpapi.html + -----------------------------------------------------------------------------------------*/ + +struct HttpApiProperties: SAMResourceProperties, Equatable { + init(method: HttpVerb? = nil, path: String? = nil) { + self.method = method + self.path = path + } + let method: HttpVerb? + let path: String? +} + +public enum HttpVerb: String, Encodable { + case GET + case POST + case PUT + case DELETE + case OPTION +} + +// MARK: SQS event definition +/*--------------------------------------------------------------------------------------- + SQS Event + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html + -----------------------------------------------------------------------------------------*/ + +/// Represents SQS queue properties. +/// When `queue` name is a shorthand YAML reference to another resource, like `!GetAtt`, it splits the shorthand into proper YAML to make the parser happy +public struct SQSEventProperties: SAMResourceProperties, Equatable { + + public var reference: Reference + public var batchSize: Int + public var enabled: Bool + + init( + byRef ref: String, + batchSize: Int, + enabled: Bool + ) { + + // when the ref is an ARN, leave it as it, otherwise, create a queue resource and pass a reference to it + if let arn = Arn(ref) { + self.reference = .arn(arn) + } else { + let logicalName = Resource.logicalName( + resourceType: "Queue", + resourceName: ref) + let queue = Resource( + type: .queue, + properties: SQSResourceProperties(queueName: ref), + name: logicalName) + self.reference = .resource(queue) + } + self.batchSize = batchSize + self.enabled = enabled + } + + init( + _ queue: Resource, + batchSize: Int, + enabled: Bool + ) { + + self.reference = .resource(queue) + self.batchSize = batchSize + self.enabled = enabled + } + + enum CodingKeys: String, CodingKey { + case reference = "Queue" + case batchSize + case enabled + } +} + +// MARK: SQS queue resource definition +/*--------------------------------------------------------------------------------------- + SQS Queue Resource + + Documentation + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html + -----------------------------------------------------------------------------------------*/ + +public struct SQSResourceProperties: SAMResourceProperties { + public let queueName: String +} + +// MARK: Simple DynamoDB table resource definition +/*--------------------------------------------------------------------------------------- + Simple DynamoDB Table Resource + + Documentation + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-simpletable.html + -----------------------------------------------------------------------------------------*/ + +public struct SimpleTableProperties: SAMResourceProperties { + let primaryKey: PrimaryKey + let tableName: String + var provisionedThroughput: ProvisionedThroughput? = nil + struct PrimaryKey: Codable { + let name: String + let type: String + } + struct ProvisionedThroughput: Codable { + let readCapacityUnits: Int + let writeCapacityUnits: Int + } +} + +// MARK: Utils + +public struct Arn: Encodable { + public let arn: String + + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + private let arnRegex = + #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-]+):([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + + public init?(_ arn: String) { + if arn.range(of: arnRegex, options: .regularExpression) != nil { + self.arn = arn + } else { + return nil + } + } + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.arn) + } + public func service() -> String? { + var result: String? = nil + + if #available(macOS 13, *) { + let regex = try! Regex(arnRegex) + if let matches = try? regex.wholeMatch(in: self.arn), + matches.count > 3, + let substring = matches[2].substring + { + result = "\(substring)" + } + } else { + let split = self.arn.split(separator: ":") + if split.count > 3 { + result = "\(split[2])" + } + } + + return result + } +} + +public enum Reference: Encodable, Equatable { + case arn(Arn) + case resource(Resource) + + // if we have an Arn, return the Arn, otherwise pass a reference with GetAtt + // https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html#sam-function-sqs-queue + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .arn(let arn): + try container.encode(arn) + case .resource(let resource): + var getAttIntrinsicFunction: [String: [String]] = [:] + getAttIntrinsicFunction["Fn::GetAtt"] = [resource.name, "Arn"] + try container.encode(getAttIntrinsicFunction) + } + } + + public static func == (lhs: Reference, rhs: Reference) -> Bool { + switch lhs { + case .arn(let lArn): + if case let .arn(rArn) = rhs { + return lArn.arn == rArn.arn + } else { + return false + } + case .resource(let lResource): + if case let .resource(rResource) = lhs { + return lResource == rResource + } else { + return false + } + } + } + +} + +extension Resource { + // Transform resourceName : + // remove space + // remove hyphen + // camel case + static func logicalName(resourceType: String, resourceName: String) -> String { + let noSpaceName = resourceName.split(separator: " ").map { $0.capitalized }.joined( + separator: "") + let noHyphenName = noSpaceName.split(separator: "-").map { $0.capitalized }.joined( + separator: "") + return resourceType.capitalized + noHyphenName + } +} diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift new file mode 100644 index 00000000..49bf7c37 --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -0,0 +1,880 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import Foundation + +// global state for serialization +// This is required because `atexit` can not capture self +private var _deploymentDescriptor: SAMDeploymentDescriptor? + +// a top level DeploymentDescriptor DSL +@resultBuilder +public struct DeploymentDescriptor { + // capture the deployment descriptor for unit tests + let samDeploymentDescriptor: SAMDeploymentDescriptor + + // MARK: Generation of the SAM Deployment Descriptor + + private init( + description: String = "A SAM template to deploy a Swift Lambda function", + resources: [Resource] + ) { + self.samDeploymentDescriptor = SAMDeploymentDescriptor( + description: description, + resources: resources + ) + + // and register it for serialization + _deploymentDescriptor = self.samDeploymentDescriptor + + // at exit of this process, + // we flush a YAML representation of the deployment descriptor to stdout + atexit { + try! DeploymentDescriptorSerializer.serialize(_deploymentDescriptor!, format: .yaml) + } + } + + // MARK: resultBuilder specific code + + // this initializer allows to declare a top level `DeploymentDescriptor { }`` + @discardableResult + public init(@DeploymentDescriptor _ builder: () -> DeploymentDescriptor) { + self = builder() + } + + public static func buildBlock( + _ description: String, + _ resources: [Resource]... + ) -> (String?, [Resource]) { + return (description, resources.flatMap { $0 }) + } + + public static func buildBlock(_ resources: [Resource]...) -> (String?, [Resource]) { + return (nil, resources.flatMap { $0 }) + } + + public static func buildFinalResult(_ function: (String?, [Resource])) -> DeploymentDescriptor { + if let description = function.0 { + return DeploymentDescriptor(description: description, resources: function.1) + } else { + return DeploymentDescriptor(resources: function.1) + } + } + + public static func buildExpression(_ expression: String) -> String { + expression + } + + public static func buildExpression(_ expression: any BuilderResource) -> [Resource] { + expression.resource() + } + +} + +public protocol BuilderResource { + func resource() -> [Resource] +} + +// MARK: Function resource + +public struct Function: BuilderResource { + private let _underlying: Resource + + enum FunctionError: Error, CustomStringConvertible { + case packageDoesNotExist(String) + + var description: String { + switch self { + case .packageDoesNotExist(let pkg): + return "Package \(pkg) does not exist" + } + } + } + + private init( + _ name: String, + properties: ServerlessFunctionProperties + ) { + self._underlying = Resource( + type: .function, + properties: properties, + name: name + ) + } + + public init( + name: String, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String? = nil, + eventSources: [Resource] = [], + environment: [String: String] = [:], + description: String? = nil + ) { + var props = ServerlessFunctionProperties( + codeUri: try! Function.packagePath(name: name, codeUri: codeURI), + architecture: architecture, + eventSources: eventSources, + environment: environment.isEmpty ? nil : SAMEnvironmentVariable(environment) + ) + props.description = description + + self.init(name, properties: props) + } + + public init( + name: String, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String? = nil + ) { + let props = ServerlessFunctionProperties( + codeUri: try! Function.packagePath(name: name, codeUri: codeURI), + architecture: architecture + ) + self.init(name, properties: props) + } + + public init( + name: String, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String? = nil, + @FunctionBuilder _ builder: () -> (String?, EventSources, [String: String]) + ) { + let (description, eventSources, environmentVariables) = builder() + let samEventSource: [Resource] = eventSources.samEventSources() + self.init( + name: name, + architecture: architecture, + codeURI: codeURI, + eventSources: samEventSource, + environment: environmentVariables, + description: description + ) + } + + // this method fails when the package does not exist at path + public func resource() -> [Resource] { + let functionResource = [ self._underlying ] + let additionalQueueResources = self.collectQueueResources() + + return functionResource + additionalQueueResources + } + + // compute the path for the lambda archive + // package path comes from three sources with this priority + // 1. the --archive-path arg + // 2. the developer supplied value in Function() definition + // 3. a default value + // func is public for testability + internal static func packagePath(name: String, codeUri: String?) throws -> String { + // propose a default path unless the --archive-path argument was used + // --archive-path argument value must match the value given to the archive plugin --output-path argument + var lambdaPackage = + ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" + if let path = codeUri { + lambdaPackage = path + } + if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { + if CommandLine.arguments.count >= optIdx + 1 { + let archiveArg = CommandLine.arguments[optIdx + 1] + lambdaPackage = "\(archiveArg)/\(name)/\(name).zip" + } + } + + // check the ZIP file exists + if !FileManager.default.fileExists(atPath: lambdaPackage) { + throw FunctionError.packageDoesNotExist(lambdaPackage) + } + + return lambdaPackage + } + + // When SQS event source is specified, the Lambda function developer + // might give a queue name, a queue Arn, or a queue resource. + // When developer gives a queue Arn there is nothing to do here + // When developer gives a queue name or a queue resource, + // the event source automatically creates the queue Resource and returns a reference to the Resource it has created + // This function collects all queue resources created by SQS event sources or passed by Lambda function developer + // to add them to the list of resources to synthesize + private func collectQueueResources() -> [Resource] { + guard let events = properties().events else { + return [] + } + return events.values.compactMap { $0 } + // first filter on event sources of type SQS where the reference is a `queue` resource + .filter { lambdaEventSource in + lambdaEventSource.type == .sqs + // var result = false + // if case .resource(_) = (lambdaEventSource.properties as? SQSEventProperties)?.reference { + // result = lambdaEventSource.type == .sqs + // } + // return result + } + // next extract the queue resource part of the sqsEventSource + .compactMap { sqsEventSource in + var result: Resource? + // should alway be true because of the filer() above + if case .resource(let resource) = (sqsEventSource.properties as? SQSEventProperties)? + .reference { + result = resource + } + return result + } + } + + // MARK: Function DSL code + + @resultBuilder + public enum FunctionBuilder { + public static func buildBlock(_ description: String) -> ( + String?, EventSources, [String: String] + ) { + return (description, EventSources.none, [:]) + } + + public static func buildBlock( + _ description: String, + _ events: EventSources + ) -> (String?, EventSources, [String: String]) { + return (description, events, [:]) + } + + public static func buildBlock(_ events: EventSources) -> ( + String?, EventSources, [String: String] + ) { + return (nil, events, [:]) + } + + public static func buildBlock( + _ description: String, + _ events: EventSources, + _ variables: EnvironmentVariables + ) -> (String?, EventSources, [String: String]) { + return (description, events, variables.environmentVariables) + } + + public static func buildBlock( + _ events: EventSources, + _ variables: EnvironmentVariables + ) -> (String?, EventSources, [String: String]) { + return (nil, events, variables.environmentVariables) + } + + public static func buildBlock( + _ description: String, + _ variables: EnvironmentVariables, + _ events: EventSources + ) -> (String?, EventSources, [String: String]) { + return (description, events, variables.environmentVariables) + } + + public static func buildBlock( + _ variables: EnvironmentVariables, + _ events: EventSources + ) -> (String?, EventSources, [String: String]) { + return (nil, events, variables.environmentVariables) + } + + @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") + public static func buildBlock( + _: String, + _: EventSources, + _: EnvironmentVariables... + ) -> (String?, EventSources?, [String: String]) { + fatalError() + } + + @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") + public static func buildBlock( + _: EventSources, + _: EnvironmentVariables... + ) -> (String?, EventSources?, [String: String]) { + fatalError() + } + } + + // MARK: function modifiers + + public func autoPublishAlias(_ name: String = "live", all: Bool = false, sha256: String? = nil) + -> Function { + var properties = properties() + properties.autoPublishAlias = name + properties.autoPublishAliasAllProperties = all + if sha256 != nil { + properties.autoPublishCodeSha256 = sha256 + } else { + properties.autoPublishCodeSha256 = FileDigest.hex(from: properties.codeUri) + } + return Function(self.name(), properties: properties) + } + + public func ephemeralStorage(_ size: Int = 512) -> Function { + var properties = properties() + properties.ephemeralStorage = ServerlessFunctionProperties.EphemeralStorage(size) + return Function(name(), properties: properties) + } + + private func getDestinations(onSuccess: Arn, onFailure: Arn) + -> ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestinationConfiguration { + let successDestination = ServerlessFunctionProperties.EventInvokeConfiguration + .EventInvokeDestination( + destination: .arn(onSuccess), + type: .destinationType(from: onSuccess) + ) + + let failureDestination = ServerlessFunctionProperties.EventInvokeConfiguration + .EventInvokeDestination( + destination: .arn(onFailure), + type: .destinationType(from: onFailure) + ) + + return ServerlessFunctionProperties.EventInvokeConfiguration + .EventInvokeDestinationConfiguration( + onSuccess: successDestination, + onFailure: failureDestination + ) + } + + private func getDestinations( + onSuccess: Resource?, onFailure: Resource? + ) + -> ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestinationConfiguration { + var successDestination: + ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestination? = nil + if let onSuccess { + successDestination = ServerlessFunctionProperties.EventInvokeConfiguration + .EventInvokeDestination( + destination: .resource(onSuccess), + type: .destinationType(from: onSuccess) + ) + } + + var failureDestination: + ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestination? = nil + if let onFailure { + failureDestination = ServerlessFunctionProperties.EventInvokeConfiguration + .EventInvokeDestination( + destination: .resource(onFailure), + type: .destinationType(from: onFailure) + ) + } + + return ServerlessFunctionProperties.EventInvokeConfiguration + .EventInvokeDestinationConfiguration( + onSuccess: successDestination, + onFailure: failureDestination + ) + } + + public func eventInvoke( + onSuccess: String? = nil, + onFailure: String? = nil, + maximumEventAgeInSeconds: Int? = nil, + maximumRetryAttempts: Int? = nil + ) -> Function { + guard let succesArn = Arn(onSuccess ?? ""), + let failureArn = Arn(onFailure ?? "") + else { + return self + } + + let destination = self.getDestinations(onSuccess: succesArn, onFailure: failureArn) + var properties = properties() + properties.eventInvokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( + destinationConfig: destination, + maximumEventAgeInSeconds: maximumEventAgeInSeconds, + maximumRetryAttempts: maximumRetryAttempts + ) + return Function(name(), properties: properties) + } + + // TODO: Add support for references to other resources (SNS, EventBridge) + // currently support reference to SQS and Lambda resources + public func eventInvoke( + onSuccess: Resource? = nil, + onFailure: Resource? = nil, + maximumEventAgeInSeconds: Int? = nil, + maximumRetryAttempts: Int? = nil + ) -> Function { + if let onSuccess { + guard onSuccess.type == .queue || onSuccess.type == .function else { + return self + } + } + + if let onFailure { + guard onFailure.type == .queue || onFailure.type == .function else { + return self + } + } + + let destination = self.getDestinations(onSuccess: onSuccess, onFailure: onFailure) + var properties = properties() + properties.eventInvokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( + destinationConfig: destination, + maximumEventAgeInSeconds: maximumEventAgeInSeconds, + maximumRetryAttempts: maximumRetryAttempts + ) + return Function(name(), properties: properties) + } + + public func fileSystem(_ arn: String, mountPoint: String) -> Function { + var properties = properties() + + if let newConfig = ServerlessFunctionProperties.FileSystemConfig( + arn: arn, + localMountPath: mountPoint + ) { + if properties.fileSystemConfigs != nil { + properties.fileSystemConfigs! += [newConfig] + } else { + properties.fileSystemConfigs = [newConfig] + } + } + return Function(name(), properties: properties) + } + + public func urlConfig( + authType: ServerlessFunctionProperties.URLConfig.AuthType = .iam, + invokeMode: ServerlessFunctionProperties.URLConfig.InvokeMode? = nil + ) + -> Function { + let builder: () -> [any CorsElement] = { [] } + return self.urlConfig( + authType: authType, + invokeMode: invokeMode, + allowCredentials: nil, + maxAge: nil, + builder + ) + } + + public func urlConfig( + authType: ServerlessFunctionProperties.URLConfig.AuthType = .iam, + invokeMode: ServerlessFunctionProperties.URLConfig.InvokeMode? = nil, + allowCredentials: Bool? = nil, + maxAge: Int? = nil, + @CorsBuilder _ builder: () -> [any CorsElement] + ) -> Function { + let corsBlock = builder() + let allowHeaders = corsBlock.filter { $0.type == .allowHeaders } + .compactMap { $0.elements() } + .reduce([], +) + let allowOrigins = corsBlock.filter { $0.type == .allowOrigins } + .compactMap { $0.elements() } + .reduce([], +) + let allowMethods = corsBlock.filter { $0.type == .allowMethods } + .compactMap { $0.elements() } + .reduce([], +) + let exposeHeaders = corsBlock.filter { $0.type == .exposeHeaders } + .compactMap { $0.elements() } + .reduce([], +) + + let cors: ServerlessFunctionProperties.URLConfig.Cors! + if allowCredentials == nil && maxAge == nil && corsBlock.isEmpty { + cors = nil + } else { + cors = ServerlessFunctionProperties.URLConfig.Cors( + allowCredentials: allowCredentials, + allowHeaders: allowHeaders.isEmpty ? nil : allowHeaders, + allowMethods: allowMethods.isEmpty ? nil : allowMethods, + allowOrigins: allowOrigins.isEmpty ? nil : allowOrigins, + exposeHeaders: exposeHeaders.isEmpty ? nil : exposeHeaders, + maxAge: maxAge + ) + } + let urlConfig = ServerlessFunctionProperties.URLConfig( + authType: authType, + cors: cors, + invokeMode: invokeMode + ) + var properties = properties() + properties.functionUrlConfig = urlConfig + return Function(name(), properties: properties) + } + + private func properties() -> ServerlessFunctionProperties { + self._underlying.properties as! ServerlessFunctionProperties + } + private func name() -> String { self._underlying.name } +} + +// MARK: Url Config Cors DSL code + +public enum CorsElementType { + case allowHeaders + case allowOrigins + case exposeHeaders + case allowMethods +} + +public protocol CorsElement { + associatedtype T where T: Encodable + var type: CorsElementType { get } + func elements() -> [String] + init(@CorsElementBuilder _ builder: () -> [T]) +} + +@resultBuilder +public enum CorsElementBuilder { + public static func buildBlock(_ header: T...) -> [T] { + header.compactMap { $0 } + } +} + +public struct AllowHeaders: CorsElement { + public var type: CorsElementType = .allowHeaders + private var _elements: [String] + public init(@CorsElementBuilder _ builder: () -> [String]) { + self._elements = builder() + } + + public func elements() -> [String] { + self._elements + } +} + +public struct AllowOrigins: CorsElement { + public var type: CorsElementType = .allowOrigins + private var _elements: [String] + public init(@CorsElementBuilder _ builder: () -> [String]) { + self._elements = builder() + } + + public func elements() -> [String] { + self._elements + } +} + +public struct ExposeHeaders: CorsElement { + public var type: CorsElementType = .exposeHeaders + private var _elements: [String] + public init(@CorsElementBuilder _ builder: () -> [String]) { + self._elements = builder() + } + + public func elements() -> [String] { + self._elements + } +} + +public struct AllowMethods: CorsElement { + public var type: CorsElementType = .allowMethods + private var _elements: [HttpVerb] + public init(@CorsElementBuilder _ builder: () -> [HttpVerb]) { + self._elements = builder() + } + + public func elements() -> [String] { + self._elements.map(\.rawValue) + } +} + +@resultBuilder +public enum CorsBuilder { + public static func buildBlock(_ corsElement: any CorsElement...) -> [any CorsElement] { + corsElement.compactMap { $0 } + } +} + +// MARK: Event Source + +public struct EventSources { + public static let none = EventSources() + private let eventSources: [Resource] + private init() { + self.eventSources = [] + } + + public init(@EventSourceBuilder _ builder: () -> [Resource]) { + self.eventSources = builder() + } + + internal func samEventSources() -> [Resource] { + self.eventSources + } + + // MARK: EventSources DSL code + + @resultBuilder + public enum EventSourceBuilder { + public static func buildBlock(_ source: Resource...) -> [Resource< + EventSourceType + >] { + source.compactMap { $0 } + } + + public static func buildExpression(_ expression: HttpApi) -> Resource { + expression.resource() + } + + public static func buildExpression(_ expression: Sqs) -> Resource { + expression.resource() + } + + public static func buildExpression(_ expression: Resource) -> Resource< + EventSourceType + > { + expression + } + } +} + +// MARK: HttpApi event source + +public struct HttpApi { + private let method: HttpVerb? + private let path: String? + private let name: String = "HttpApiEvent" + public init( + method: HttpVerb? = nil, + path: String? = nil + ) { + self.method = method + self.path = path + } + + internal func resource() -> Resource { + var properties: SAMResourceProperties? + if self.method != nil || self.path != nil { + properties = HttpApiProperties(method: self.method, path: self.path) + } + + return Resource( + type: .httpApi, + properties: properties, + name: self.name + ) + } +} + +// MARK: SQS Event Source + +public struct Sqs { + private let name: String + private var queueRef: String? + private var queue: Queue? + public var batchSize: Int = 10 + public var enabled: Bool = true + + public init(name: String = "SQSEvent") { + self.name = name + } + + public init( + name: String = "SQSEvent", + _ queue: String, + batchSize: Int = 10, + enabled: Bool = true + ) { + self.name = name + self.queueRef = queue + self.batchSize = batchSize + self.enabled = enabled + } + + public init( + name: String = "SQSEvent", + _ queue: Queue, + batchSize: Int = 10, + enabled: Bool = true + ) { + self.name = name + self.queue = queue + self.batchSize = batchSize + self.enabled = enabled + } + + public func queue(logicalName: String, physicalName: String) -> Sqs { + let queue = Queue(logicalName: logicalName, physicalName: physicalName) + return Sqs(name: self.name, queue) + } + + internal func resource() -> Resource { + var properties: SQSEventProperties! + if self.queue != nil { + properties = SQSEventProperties( + self.queue!.resource()[0], + batchSize: self.batchSize, + enabled: self.enabled + ) + + } else if self.queueRef != nil { + properties = SQSEventProperties( + byRef: self.queueRef!, + batchSize: self.batchSize, + enabled: self.enabled + ) + } else { + fatalError("Either queue or queueRef muts have a value") + } + + return Resource( + type: .sqs, + properties: properties, + name: self.name + ) + } +} + +// MARK: Environment Variable + +public struct EnvironmentVariables { + internal let environmentVariables: [String: String] + + // MARK: EnvironmentVariable DSL code + + public init(@EnvironmentVariablesBuilder _ builder: () -> [String: String]) { + self.environmentVariables = builder() + } + + @resultBuilder + public enum EnvironmentVariablesBuilder { + public static func buildBlock(_ variables: [String: String]...) -> [String: String] { + // merge an array of dictionaries into a single dictionary. + // existing values are preserved + var mergedDictKeepCurrent: [String: String] = [:] + variables.forEach { dict in + mergedDictKeepCurrent = mergedDictKeepCurrent.merging(dict) { current, _ in current } + } + return mergedDictKeepCurrent + } + } +} + +// MARK: Queue top level resource +//TODO : do we really need two Queue and Sqs struct ? +public struct Queue: BuilderResource { + private let _underlying: Resource + + public init(logicalName: String, physicalName: String) { + let properties = SQSResourceProperties(queueName: physicalName) + + self._underlying = Resource( + type: .queue, + properties: properties, + name: logicalName + ) + } + + public func resource() -> [Resource] { [_underlying] } +} + +// MARK: Table top level resource +public struct Table: BuilderResource { + private let _underlying: Resource + private init( + logicalName: String, + properties: SimpleTableProperties + ) { + self._underlying = Resource( + type: .table, + properties: properties, + name: logicalName + ) + } + + public init( + logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String + ) { + let primaryKey = SimpleTableProperties.PrimaryKey( + name: primaryKeyName, + type: primaryKeyType + ) + let properties = SimpleTableProperties( + primaryKey: primaryKey, + tableName: physicalName + ) + self.init(logicalName: logicalName, properties: properties) + } + + public func resource() -> [Resource] { [ self._underlying ] } + + public func provisionedThroughput(readCapacityUnits: Int, writeCapacityUnits: Int) -> Table { + var properties = self._underlying.properties as! SimpleTableProperties // use as! is safe, it it fails, it is a programming error + properties.provisionedThroughput = SimpleTableProperties.ProvisionedThroughput( + readCapacityUnits: readCapacityUnits, + writeCapacityUnits: writeCapacityUnits + ) + return Table( + logicalName: self._underlying.name, + properties: properties + ) + } +} + +// MARK: Serialization code + +extension SAMDeploymentDescriptor { + internal func toJSON(pretty: Bool = true) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + if pretty { + encoder.outputFormatting = [encoder.outputFormatting, .prettyPrinted] + } + let jsonData = try! encoder.encode(self) + return String(data: jsonData, encoding: .utf8)! + } + + internal func toYAML() -> String { + let encoder = YAMLEncoder() + encoder.keyEncodingStrategy = .camelCase + let yaml = try! encoder.encode(self) + + return String(data: yaml, encoding: .utf8)! + } +} + +private struct DeploymentDescriptorSerializer { + enum SerializeFormat { + case json + case yaml + } + + // dump the JSON representation of the deployment descriptor to the given file descriptor + // by default, it outputs on fileDesc = 1, which is stdout + static func serialize( + _ deploymentDescriptor: SAMDeploymentDescriptor, + format: SerializeFormat, + to fileDesc: Int32 = 1 + ) throws { + // do not output the deployment descriptor on stdout when running unit tests + if Thread.current.isRunningXCTest { return } + + guard let fd = fdopen(fileDesc, "w") else { return } + switch format { + case .json: fputs(deploymentDescriptor.toJSON(), fd) + case .yaml: fputs(deploymentDescriptor.toYAML(), fd) + } + + fclose(fd) + } +} + +// MARK: Support code for unit testing + +// Detect when running inside a unit test +// This allows to avoid calling `fatalError()` or to print the deployment descriptor when unit testing +// inspired from https://stackoverflow.com/a/59732115/663360 +extension Thread { + var isRunningXCTest: Bool { + self.threadDictionary.allKeys + .contains { + ($0 as? String)? + .range(of: "XCTest", options: .caseInsensitive) != nil + } + } +} diff --git a/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift b/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift new file mode 100644 index 00000000..6a63b25f --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift @@ -0,0 +1,72 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import CryptoKit +import Foundation + +class FileDigest { + + public static func hex(from filePath: String?) -> String? { + guard let fp = filePath else { + return nil + } + + return try? FileDigest().update(path: fp).finalize() + } + + enum InputStreamError: Error { + case createFailed(String) + case readFailed + } + + private var digest = SHA256() + + func update(path: String) throws -> FileDigest { + guard let inputStream = InputStream(fileAtPath: path) else { + throw InputStreamError.createFailed(path) + } + return try update(inputStream: inputStream) + } + + private func update(inputStream: InputStream) throws -> FileDigest { + inputStream.open() + defer { + inputStream.close() + } + + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + var bytesRead = inputStream.read(buffer, maxLength: bufferSize) + while bytesRead > 0 { + self.update(bytes: buffer, length: bytesRead) + bytesRead = inputStream.read(buffer, maxLength: bufferSize) + } + if bytesRead < 0 { + // Stream error occured + throw (inputStream.streamError ?? InputStreamError.readFailed) + } + return self + } + + private func update(bytes: UnsafeMutablePointer, length: Int) { + let data = Data(bytes: bytes, count: length) + digest.update(data: data) + } + + func finalize() -> String { + let digest = digest.finalize() + return digest.compactMap { String(format: "%02x", $0) }.joined() + } + +} diff --git a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift new file mode 100644 index 00000000..967ecc40 --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift @@ -0,0 +1,1257 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +// This is based on Foundation's JSONEncoder +// https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift + +import Foundation + +/// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary` +/// containing `Encodable` values +/// +private protocol _YAMLStringDictionaryEncodableMarker {} + +extension Dictionary: _YAMLStringDictionaryEncodableMarker where Key == String, Value: Encodable {} + +// ===----------------------------------------------------------------------===// +// YAML Encoder +// ===----------------------------------------------------------------------===// + +/// `YAMLEncoder` facilitates the encoding of `Encodable` values into YAML. +open class YAMLEncoder { + // MARK: Options + + /// The formatting of the output YAML data. + public struct OutputFormatting: OptionSet { + /// The format's default value. + public let rawValue: UInt + + /// Creates an OutputFormatting value with the given raw value. + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Produce human-readable YAML with indented output. + // public static let prettyPrinted = OutputFormatting(rawValue: 1 << 0) + + /// Produce JSON with dictionary keys sorted in lexicographic order. + public static let sortedKeys = OutputFormatting(rawValue: 1 << 1) + + /// By default slashes get escaped ("/" → "\/", "http://apple.com/" → "http:\/\/apple.com\/") + /// for security reasons, allowing outputted YAML to be safely embedded within HTML/XML. + /// In contexts where this escaping is unnecessary, the YAML is known to not be embedded, + /// or is intended only for display, this option avoids this escaping. + public static let withoutEscapingSlashes = OutputFormatting(rawValue: 1 << 3) + } + + /// The strategy to use for encoding `Date` values. + public enum DateEncodingStrategy { + /// Defer to `Date` for choosing an encoding. This is the default strategy. + case deferredToDate + + /// Encode the `Date` as a UNIX timestamp (as a YAML number). + case secondsSince1970 + + /// Encode the `Date` as UNIX millisecond timestamp (as a YAML number). + case millisecondsSince1970 + + /// Encode the `Date` as an ISO-8601-formatted string (in RFC 3339 format). + @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) + case iso8601 + + /// Encode the `Date` as a string formatted by the given formatter. + case formatted(DateFormatter) + + /// Encode the `Date` as a custom value encoded by the given closure. + /// + /// If the closure fails to encode a value into the given encoder, the encoder will encode an empty automatic container in its place. + case custom((Date, Encoder) throws -> Void) + } + + /// The strategy to use for encoding `Data` values. + public enum DataEncodingStrategy { + /// Defer to `Data` for choosing an encoding. + case deferredToData + + /// Encoded the `Data` as a Base64-encoded string. This is the default strategy. + case base64 + + /// Encode the `Data` as a custom value encoded by the given closure. + /// + /// If the closure fails to encode a value into the given encoder, the encoder will encode an empty automatic container in its place. + case custom((Data, Encoder) throws -> Void) + } + + /// The strategy to use for non-YAML-conforming floating-point values (IEEE 754 infinity and NaN). + public enum NonConformingFloatEncodingStrategy { + /// Throw upon encountering non-conforming values. This is the default strategy. + case `throw` + + /// Encode the values using the given representation strings. + case convertToString(positiveInfinity: String, negativeInfinity: String, nan: String) + } + + /// The strategy to use for automatically changing the value of keys before encoding. + public enum KeyEncodingStrategy { + /// Use the keys specified by each type. This is the default strategy. + case useDefaultKeys + + /// convert keyname to camel case. + /// for example myMaxValue becomes MyMaxValue + case camelCase + + fileprivate static func _convertToCamelCase(_ stringKey: String) -> String { + return stringKey.prefix(1).capitalized + stringKey.dropFirst() + } + } + + /// The output format to produce. Defaults to `withoutEscapingSlashes` for YAML. + open var outputFormatting: OutputFormatting = [OutputFormatting.withoutEscapingSlashes] + + /// The strategy to use in encoding dates. Defaults to `.deferredToDate`. + open var dateEncodingStrategy: DateEncodingStrategy = .deferredToDate + + /// The strategy to use in encoding binary data. Defaults to `.base64`. + open var dataEncodingStrategy: DataEncodingStrategy = .base64 + + /// The strategy to use in encoding non-conforming numbers. Defaults to `.throw`. + open var nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy = .throw + + /// The strategy to use for encoding keys. Defaults to `.useDefaultKeys`. + open var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys + + /// Contextual user-provided information for use during encoding. + open var userInfo: [CodingUserInfoKey: Any] = [:] + + /// the number of space characters for a single indent + public static let singleIndent: Int = 3 + + /// Options set on the top-level encoder to pass down the encoding hierarchy. + fileprivate struct _Options { + let dateEncodingStrategy: DateEncodingStrategy + let dataEncodingStrategy: DataEncodingStrategy + let nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy + let keyEncodingStrategy: KeyEncodingStrategy + let userInfo: [CodingUserInfoKey: Any] + } + + /// The options set on the top-level encoder. + fileprivate var options: _Options { + return _Options( + dateEncodingStrategy: dateEncodingStrategy, + dataEncodingStrategy: dataEncodingStrategy, + nonConformingFloatEncodingStrategy: nonConformingFloatEncodingStrategy, + keyEncodingStrategy: keyEncodingStrategy, + userInfo: userInfo) + } + + // MARK: - Constructing a YAML Encoder + + /// Initializes `self` with default strategies. + public init() {} + + // MARK: - Encoding Values + + /// Encodes the given top-level value and returns its YAML representation. + /// + /// - parameter value: The value to encode. + /// - returns: A new `Data` value containing the encoded YAML data. + /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`. + /// - throws: An error if any value throws an error during encoding. + open func encode(_ value: T) throws -> Data { + let value: YAMLValue = try encodeAsYAMLValue(value) + let writer = YAMLValue.Writer(options: self.outputFormatting) + let bytes = writer.writeValue(value) + + return Data(bytes) + } + + func encodeAsYAMLValue(_ value: T) throws -> YAMLValue { + let encoder = YAMLEncoderImpl(options: self.options, codingPath: []) + guard let topLevel = try encoder.wrapEncodable(value, for: nil) else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], debugDescription: "Top-level \(T.self) did not encode any values.")) + } + + return topLevel + } +} + +// MARK: - _YAMLEncoder + +private enum YAMLFuture { + case value(YAMLValue) + case encoder(YAMLEncoderImpl) + case nestedArray(RefArray) + case nestedObject(RefObject) + + class RefArray { + private(set) var array: [YAMLFuture] = [] + + init() { + self.array.reserveCapacity(10) + } + + @inline(__always) func append(_ element: YAMLValue) { + self.array.append(.value(element)) + } + + @inline(__always) func append(_ encoder: YAMLEncoderImpl) { + self.array.append(.encoder(encoder)) + } + + @inline(__always) func appendArray() -> RefArray { + let array = RefArray() + self.array.append(.nestedArray(array)) + return array + } + + @inline(__always) func appendObject() -> RefObject { + let object = RefObject() + self.array.append(.nestedObject(object)) + return object + } + + var values: [YAMLValue] { + self.array.map { (future) -> YAMLValue in + switch future { + case .value(let value): + return value + case .nestedArray(let array): + return .array(array.values) + case .nestedObject(let object): + return .object(object.values) + case .encoder(let encoder): + return encoder.value ?? .object([:]) + } + } + } + } + + class RefObject { + private(set) var dict: [String: YAMLFuture] = [:] + + init() { + self.dict.reserveCapacity(20) + } + + @inline(__always) func set(_ value: YAMLValue, for key: String) { + self.dict[key] = .value(value) + } + + @inline(__always) func setArray(for key: String) -> RefArray { + switch self.dict[key] { + case .encoder: + preconditionFailure("For key \"\(key)\" an encoder has already been created.") + case .nestedObject: + preconditionFailure("For key \"\(key)\" a keyed container has already been created.") + case .nestedArray(let array): + return array + case .none, .value: + let array = RefArray() + dict[key] = .nestedArray(array) + return array + } + } + + @inline(__always) func setObject(for key: String) -> RefObject { + switch self.dict[key] { + case .encoder: + preconditionFailure("For key \"\(key)\" an encoder has already been created.") + case .nestedObject(let object): + return object + case .nestedArray: + preconditionFailure("For key \"\(key)\" a unkeyed container has already been created.") + case .none, .value: + let object = RefObject() + dict[key] = .nestedObject(object) + return object + } + } + + @inline(__always) func set(_ encoder: YAMLEncoderImpl, for key: String) { + switch self.dict[key] { + case .encoder: + preconditionFailure("For key \"\(key)\" an encoder has already been created.") + case .nestedObject: + preconditionFailure("For key \"\(key)\" a keyed container has already been created.") + case .nestedArray: + preconditionFailure("For key \"\(key)\" a unkeyed container has already been created.") + case .none, .value: + dict[key] = .encoder(encoder) + } + } + + var values: [String: YAMLValue] { + self.dict.mapValues { (future) -> YAMLValue in + switch future { + case .value(let value): + return value + case .nestedArray(let array): + return .array(array.values) + case .nestedObject(let object): + return .object(object.values) + case .encoder(let encoder): + return encoder.value ?? .object([:]) + } + } + } + } +} + +private class YAMLEncoderImpl { + let options: YAMLEncoder._Options + let codingPath: [CodingKey] + var userInfo: [CodingUserInfoKey: Any] { + options.userInfo + } + + var singleValue: YAMLValue? + var array: YAMLFuture.RefArray? + var object: YAMLFuture.RefObject? + + var value: YAMLValue? { + if let object = self.object { + return .object(object.values) + } + if let array = self.array { + return .array(array.values) + } + return self.singleValue + } + + init(options: YAMLEncoder._Options, codingPath: [CodingKey]) { + self.options = options + self.codingPath = codingPath + } +} + +extension YAMLEncoderImpl: Encoder { + func container(keyedBy _: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { + if let _ = object { + let container = YAMLKeyedEncodingContainer(impl: self, codingPath: codingPath) + return KeyedEncodingContainer(container) + } + + guard self.singleValue == nil, self.array == nil else { + preconditionFailure() + } + + self.object = YAMLFuture.RefObject() + let container = YAMLKeyedEncodingContainer(impl: self, codingPath: codingPath) + return KeyedEncodingContainer(container) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + if let _ = array { + return YAMLUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + } + + guard self.singleValue == nil, self.object == nil else { + preconditionFailure() + } + + self.array = YAMLFuture.RefArray() + return YAMLUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + guard self.object == nil, self.array == nil else { + preconditionFailure() + } + + return YAMLSingleValueEncodingContainer(impl: self, codingPath: self.codingPath) + } +} + +// this is a private protocol to implement convenience methods directly on the EncodingContainers + +extension YAMLEncoderImpl: _SpecialTreatmentEncoder { + var impl: YAMLEncoderImpl { + return self + } + + // untyped escape hatch. needed for `wrapObject` + func wrapUntyped(_ encodable: Encodable) throws -> YAMLValue { + switch encodable { + case let date as Date: + return try self.wrapDate(date, for: nil) + case let data as Data: + return try self.wrapData(data, for: nil) + case let url as URL: + return .string(url.absoluteString) + case let decimal as Decimal: + return .number(decimal.description) + case let object as [String: Encodable]: // this emits a warning, but it works perfectly + return try self.wrapObject(object, for: nil) + default: + try encodable.encode(to: self) + return self.value ?? .object([:]) + } + } +} + +private protocol _SpecialTreatmentEncoder { + var codingPath: [CodingKey] { get } + var options: YAMLEncoder._Options { get } + var impl: YAMLEncoderImpl { get } +} + +extension _SpecialTreatmentEncoder { + @inline(__always) fileprivate func wrapFloat( + _ float: F, for additionalKey: CodingKey? + ) throws -> YAMLValue { + guard !float.isNaN, !float.isInfinite else { + if case .convertToString(let posInfString, let negInfString, let nanString) = self.options + .nonConformingFloatEncodingStrategy + { + switch float { + case F.infinity: + return .string(posInfString) + case -F.infinity: + return .string(negInfString) + default: + // must be nan in this case + return .string(nanString) + } + } + + var path = self.codingPath + if let additionalKey = additionalKey { + path.append(additionalKey) + } + + throw EncodingError.invalidValue( + float, + .init( + codingPath: path, + debugDescription: "Unable to encode \(F.self).\(float) directly in YAML." + )) + } + + var string = float.description + if string.hasSuffix(".0") { + string.removeLast(2) + } + return .number(string) + } + + fileprivate func wrapEncodable(_ encodable: E, for additionalKey: CodingKey?) throws + -> YAMLValue? + { + switch encodable { + case let date as Date: + return try self.wrapDate(date, for: additionalKey) + case let data as Data: + return try self.wrapData(data, for: additionalKey) + case let url as URL: + return .string(url.absoluteString) + case let decimal as Decimal: + return .number(decimal.description) + case let object as _YAMLStringDictionaryEncodableMarker: + return try self.wrapObject(object as! [String: Encodable], for: additionalKey) + default: + let encoder = self.getEncoder(for: additionalKey) + try encodable.encode(to: encoder) + return encoder.value + } + } + + func wrapDate(_ date: Date, for additionalKey: CodingKey?) throws -> YAMLValue { + switch self.options.dateEncodingStrategy { + case .deferredToDate: + let encoder = self.getEncoder(for: additionalKey) + try date.encode(to: encoder) + return encoder.value ?? .null + + case .secondsSince1970: + return .number(date.timeIntervalSince1970.description) + + case .millisecondsSince1970: + return .number((date.timeIntervalSince1970 * 1000).description) + + case .iso8601: + if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) { + return .string(_iso8601Formatter.string(from: date)) + } else { + fatalError("ISO8601DateFormatter is unavailable on this platform.") + } + + case .formatted(let formatter): + return .string(formatter.string(from: date)) + + case .custom(let closure): + let encoder = self.getEncoder(for: additionalKey) + try closure(date, encoder) + // The closure didn't encode anything. Return the default keyed container. + return encoder.value ?? .object([:]) + } + } + + func wrapData(_ data: Data, for additionalKey: CodingKey?) throws -> YAMLValue { + switch self.options.dataEncodingStrategy { + case .deferredToData: + let encoder = self.getEncoder(for: additionalKey) + try data.encode(to: encoder) + return encoder.value ?? .null + + case .base64: + let base64 = data.base64EncodedString() + return .string(base64) + + case .custom(let closure): + let encoder = self.getEncoder(for: additionalKey) + try closure(data, encoder) + // The closure didn't encode anything. Return the default keyed container. + return encoder.value ?? .object([:]) + } + } + + func wrapObject(_ object: [String: Encodable], for additionalKey: CodingKey?) throws -> YAMLValue + { + var baseCodingPath = self.codingPath + if let additionalKey = additionalKey { + baseCodingPath.append(additionalKey) + } + var result = [String: YAMLValue]() + result.reserveCapacity(object.count) + + try object.forEach { (key, value) in + var elemCodingPath = baseCodingPath + elemCodingPath.append(_YAMLKey(stringValue:key)) + + let encoder = YAMLEncoderImpl(options: self.options, codingPath: elemCodingPath) + + var convertedKey = key + if self.options.keyEncodingStrategy == .camelCase { + convertedKey = YAMLEncoder.KeyEncodingStrategy._convertToCamelCase(key) + } + result[convertedKey] = try encoder.wrapUntyped(value) + } + + return .object(result) + } + + fileprivate func getEncoder(for additionalKey: CodingKey?) -> YAMLEncoderImpl { + if let additionalKey = additionalKey { + var newCodingPath = self.codingPath + newCodingPath.append(additionalKey) + return YAMLEncoderImpl(options: self.options, codingPath: newCodingPath) + } + + return self.impl + } +} + +private struct YAMLKeyedEncodingContainer: KeyedEncodingContainerProtocol, + _SpecialTreatmentEncoder +{ + typealias Key = K + + let impl: YAMLEncoderImpl + let object: YAMLFuture.RefObject + let codingPath: [CodingKey] + + private var firstValueWritten: Bool = false + fileprivate var options: YAMLEncoder._Options { + return self.impl.options + } + + init(impl: YAMLEncoderImpl, codingPath: [CodingKey]) { + self.impl = impl + self.object = impl.object! + self.codingPath = codingPath + } + + // used for nested containers + init(impl: YAMLEncoderImpl, object: YAMLFuture.RefObject, codingPath: [CodingKey]) { + self.impl = impl + self.object = object + self.codingPath = codingPath + } + + private func _converted(_ key: Key) -> CodingKey { + switch self.options.keyEncodingStrategy { + case .useDefaultKeys: + return key + case .camelCase: + let newKeyString = YAMLEncoder.KeyEncodingStrategy._convertToCamelCase(key.stringValue) + return _YAMLKey(stringValue: newKeyString, intValue: key.intValue) + } + } + + mutating func encodeNil(forKey key: Self.Key) throws { + self.object.set(.null, for: self._converted(key).stringValue) + } + + mutating func encode(_ value: Bool, forKey key: Self.Key) throws { + self.object.set(.bool(value), for: self._converted(key).stringValue) + } + + mutating func encode(_ value: String, forKey key: Self.Key) throws { + self.object.set(.string(value), for: self._converted(key).stringValue) + } + + mutating func encode(_ value: Double, forKey key: Self.Key) throws { + try encodeFloatingPoint(value, key: self._converted(key)) + } + + mutating func encode(_ value: Float, forKey key: Self.Key) throws { + try encodeFloatingPoint(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int8, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int16, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int32, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int64, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt8, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt16, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt32, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt64, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: T, forKey key: Self.Key) throws where T: Encodable { + let convertedKey = self._converted(key) + let encoded = try self.wrapEncodable(value, for: convertedKey) + self.object.set(encoded ?? .object([:]), for: convertedKey.stringValue) + } + + mutating func nestedContainer(keyedBy _: NestedKey.Type, forKey key: Self.Key) + -> KeyedEncodingContainer where NestedKey: CodingKey + { + let convertedKey = self._converted(key) + let newPath = self.codingPath + [convertedKey] + let object = self.object.setObject(for: convertedKey.stringValue) + let nestedContainer = YAMLKeyedEncodingContainer( + impl: impl, object: object, codingPath: newPath) + return KeyedEncodingContainer(nestedContainer) + } + + mutating func nestedUnkeyedContainer(forKey key: Self.Key) -> UnkeyedEncodingContainer { + let convertedKey = self._converted(key) + let newPath = self.codingPath + [convertedKey] + let array = self.object.setArray(for: convertedKey.stringValue) + let nestedContainer = YAMLUnkeyedEncodingContainer( + impl: impl, array: array, codingPath: newPath) + return nestedContainer + } + + mutating func superEncoder() -> Encoder { + let newEncoder = self.getEncoder(for: _YAMLKey.super) + self.object.set(newEncoder, for: _YAMLKey.super.stringValue) + return newEncoder + } + + mutating func superEncoder(forKey key: Self.Key) -> Encoder { + let convertedKey = self._converted(key) + let newEncoder = self.getEncoder(for: convertedKey) + self.object.set(newEncoder, for: convertedKey.stringValue) + return newEncoder + } +} + +extension YAMLKeyedEncodingContainer { + @inline(__always) + private mutating func encodeFloatingPoint( + _ float: F, key: CodingKey + ) throws { + let value = try self.wrapFloat(float, for: key) + self.object.set(value, for: key.stringValue) + } + + @inline(__always) private mutating func encodeFixedWidthInteger( + _ value: N, key: CodingKey + ) throws { + self.object.set(.number(value.description), for: key.stringValue) + } +} + +private struct YAMLUnkeyedEncodingContainer: UnkeyedEncodingContainer, _SpecialTreatmentEncoder { + let impl: YAMLEncoderImpl + let array: YAMLFuture.RefArray + let codingPath: [CodingKey] + + var count: Int { + self.array.array.count + } + private var firstValueWritten: Bool = false + fileprivate var options: YAMLEncoder._Options { + return self.impl.options + } + + init(impl: YAMLEncoderImpl, codingPath: [CodingKey]) { + self.impl = impl + self.array = impl.array! + self.codingPath = codingPath + } + + // used for nested containers + init(impl: YAMLEncoderImpl, array: YAMLFuture.RefArray, codingPath: [CodingKey]) { + self.impl = impl + self.array = array + self.codingPath = codingPath + } + + mutating func encodeNil() throws { + self.array.append(.null) + } + + mutating func encode(_ value: Bool) throws { + self.array.append(.bool(value)) + } + + mutating func encode(_ value: String) throws { + self.array.append(.string(value)) + } + + mutating func encode(_ value: Double) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: Float) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: Int) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: T) throws where T: Encodable { + let key = _YAMLKey(stringValue: "Index \(self.count)", intValue: self.count) + let encoded = try self.wrapEncodable(value, for: key) + self.array.append(encoded ?? .object([:])) + } + + mutating func nestedContainer(keyedBy _: NestedKey.Type) -> KeyedEncodingContainer< + NestedKey + > where NestedKey: CodingKey { + let newPath = self.codingPath + [_YAMLKey(index: self.count)] + let object = self.array.appendObject() + let nestedContainer = YAMLKeyedEncodingContainer( + impl: impl, object: object, codingPath: newPath) + return KeyedEncodingContainer(nestedContainer) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + let newPath = self.codingPath + [_YAMLKey(index: self.count)] + let array = self.array.appendArray() + let nestedContainer = YAMLUnkeyedEncodingContainer( + impl: impl, array: array, codingPath: newPath) + return nestedContainer + } + + mutating func superEncoder() -> Encoder { + let encoder = self.getEncoder(for: _YAMLKey(index: self.count)) + self.array.append(encoder) + return encoder + } +} + +extension YAMLUnkeyedEncodingContainer { + @inline(__always) private mutating func encodeFixedWidthInteger(_ value: N) + throws + { + self.array.append(.number(value.description)) + } + + @inline(__always) + private mutating func encodeFloatingPoint(_ float: F) + throws + { + let value = try self.wrapFloat(float, for: _YAMLKey(index: self.count)) + self.array.append(value) + } +} + +private struct YAMLSingleValueEncodingContainer: SingleValueEncodingContainer, + _SpecialTreatmentEncoder +{ + let impl: YAMLEncoderImpl + let codingPath: [CodingKey] + + private var firstValueWritten: Bool = false + fileprivate var options: YAMLEncoder._Options { + return self.impl.options + } + + init(impl: YAMLEncoderImpl, codingPath: [CodingKey]) { + self.impl = impl + self.codingPath = codingPath + } + + mutating func encodeNil() throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .null + } + + mutating func encode(_ value: Bool) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .bool(value) + } + + mutating func encode(_ value: Int) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Float) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: Double) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: String) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .string(value) + } + + mutating func encode(_ value: T) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = try self.wrapEncodable(value, for: nil) + } + + func preconditionCanEncodeNewValue() { + precondition( + self.impl.singleValue == nil, + "Attempt to encode value through single value container when previously value already encoded." + ) + } +} + +extension YAMLSingleValueEncodingContainer { + @inline(__always) private mutating func encodeFixedWidthInteger(_ value: N) + throws + { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .number(value.description) + } + + @inline(__always) + private mutating func encodeFloatingPoint(_ float: F) + throws + { + self.preconditionCanEncodeNewValue() + let value = try self.wrapFloat(float, for: nil) + self.impl.singleValue = value + } +} + +extension YAMLValue { + + fileprivate struct Writer { + let options: YAMLEncoder.OutputFormatting + + init(options: YAMLEncoder.OutputFormatting) { + self.options = options + } + + func writeValue(_ value: YAMLValue) -> [UInt8] { + var bytes = [UInt8]() + self.writeValuePretty(value, into: &bytes) + return bytes + } + + private func addInset(to bytes: inout [UInt8], depth: Int) { + bytes.append(contentsOf: [UInt8](repeating: ._space, count: depth * YAMLEncoder.singleIndent)) + } + + private func writeValuePretty(_ value: YAMLValue, into bytes: inout [UInt8], depth: Int = 0) { + switch value { + case .null: + if bytes.count > 0 { bytes.append(contentsOf: [._space]) } + bytes.append(contentsOf: [UInt8]._null) + case .bool(true): + if bytes.count > 0 { bytes.append(contentsOf: [._space]) } + bytes.append(contentsOf: [UInt8]._true) + case .bool(false): + if bytes.count > 0 { bytes.append(contentsOf: [._space]) } + bytes.append(contentsOf: [UInt8]._false) + case .string(let string): + if bytes.count > 0 { bytes.append(contentsOf: [._space]) } + self.encodeString(string, to: &bytes) + case .number(let string): + if bytes.count > 0 { bytes.append(contentsOf: [._space]) } + bytes.append(contentsOf: string.utf8) + case .array(let array): + var iterator = array.makeIterator() + while let item = iterator.next() { + bytes.append(contentsOf: [._newline]) + self.addInset(to: &bytes, depth: depth) + bytes.append(contentsOf: [._dash]) + self.writeValuePretty(item, into: &bytes, depth: depth + 1) + } + case .object(let dict): + if options.contains(.sortedKeys) { + let sorted = dict.sorted { $0.key < $1.key } + self.writePrettyObject(sorted, into: &bytes) + } else { + self.writePrettyObject(dict, into: &bytes, depth: depth) + } + } + } + + private func writePrettyObject( + _ object: Object, into bytes: inout [UInt8], depth: Int = 0 + ) + where Object.Element == (key: String, value: YAMLValue) { + var iterator = object.makeIterator() + + while let (key, value) = iterator.next() { + // add a new line when other objects are present already + if bytes.count > 0 { + bytes.append(contentsOf: [._newline]) + } + self.addInset(to: &bytes, depth: depth) + // key + self.encodeString(key, to: &bytes) + bytes.append(contentsOf: [._colon]) + // value + self.writeValuePretty(value, into: &bytes, depth: depth + 1) + } + // self.addInset(to: &bytes, depth: depth) + } + + private func encodeString(_ string: String, to bytes: inout [UInt8]) { + let stringBytes = string.utf8 + var startCopyIndex = stringBytes.startIndex + var nextIndex = startCopyIndex + + while nextIndex != stringBytes.endIndex { + switch stringBytes[nextIndex] { + case 0..<32, UInt8(ascii: "\""), UInt8(ascii: "\\"): + // All Unicode characters may be placed within the + // quotation marks, except for the characters that MUST be escaped: + // quotation mark, reverse solidus, and the control characters (U+0000 + // through U+001F). + // https://tools.ietf.org/html/rfc8259#section-7 + + // copy the current range over + bytes.append(contentsOf: stringBytes[startCopyIndex.. UInt8 { + switch value { + case 0...9: + return value + UInt8(ascii: "0") + case 10...15: + return value - 10 + UInt8(ascii: "a") + default: + preconditionFailure() + } + } + bytes.append(UInt8(ascii: "\\")) + bytes.append(UInt8(ascii: "u")) + bytes.append(UInt8(ascii: "0")) + bytes.append(UInt8(ascii: "0")) + let first = stringBytes[nextIndex] / 16 + let remaining = stringBytes[nextIndex] % 16 + bytes.append(valueToAscii(first)) + bytes.append(valueToAscii(remaining)) + } + + nextIndex = stringBytes.index(after: nextIndex) + startCopyIndex = nextIndex + case UInt8(ascii: "/") where options.contains(.withoutEscapingSlashes) == false: + bytes.append(contentsOf: stringBytes[startCopyIndex..( + _ value: T, at codingPath: [CodingKey] + ) -> EncodingError { + let valueDescription: String + if value == T.infinity { + valueDescription = "\(T.self).infinity" + } else if value == -T.infinity { + valueDescription = "-\(T.self).infinity" + } else { + valueDescription = "\(T.self).nan" + } + + let debugDescription = + "Unable to encode \(valueDescription) directly in YAML. Use YAMLEncoder.NonConformingFloatEncodingStrategy.convertToString to specify how the value should be encoded." + return .invalidValue( + value, EncodingError.Context(codingPath: codingPath, debugDescription: debugDescription)) + } +} + +enum YAMLValue: Equatable { + case string(String) + case number(String) + case bool(Bool) + case null + + case array([YAMLValue]) + case object([String: YAMLValue]) +} + +extension YAMLValue { + fileprivate var isValue: Bool { + switch self { + case .array, .object: + return false + case .null, .number, .string, .bool: + return true + } + } + + fileprivate var isContainer: Bool { + switch self { + case .array, .object: + return true + case .null, .number, .string, .bool: + return false + } + } +} + +extension YAMLValue { + fileprivate var debugDataTypeDescription: String { + switch self { + case .array: + return "an array" + case .bool: + return "bool" + case .number: + return "a number" + case .string: + return "a string" + case .object: + return "a dictionary" + case .null: + return "null" + } + } +} + +extension UInt8 { + + internal static let _space = UInt8(ascii: " ") + internal static let _return = UInt8(ascii: "\r") + internal static let _newline = UInt8(ascii: "\n") + internal static let _tab = UInt8(ascii: "\t") + + internal static let _colon = UInt8(ascii: ":") + internal static let _comma = UInt8(ascii: ",") + + internal static let _openbrace = UInt8(ascii: "{") + internal static let _closebrace = UInt8(ascii: "}") + + internal static let _openbracket = UInt8(ascii: "[") + internal static let _closebracket = UInt8(ascii: "]") + + internal static let _quote = UInt8(ascii: "\"") + internal static let _backslash = UInt8(ascii: "\\") + + internal static let _dash = UInt8(ascii: "-") + +} + +extension Array where Element == UInt8 { + + internal static let _true = [ + UInt8(ascii: "t"), UInt8(ascii: "r"), UInt8(ascii: "u"), UInt8(ascii: "e"), + ] + internal static let _false = [ + UInt8(ascii: "f"), UInt8(ascii: "a"), UInt8(ascii: "l"), UInt8(ascii: "s"), UInt8(ascii: "e"), + ] + internal static let _null = [ + UInt8(ascii: "n"), UInt8(ascii: "u"), UInt8(ascii: "l"), UInt8(ascii: "l"), + ] + +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift new file mode 100644 index 00000000..38c690d4 --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift @@ -0,0 +1,199 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +@testable import AWSLambdaDeploymentDescriptor +import XCTest + +class DeploymentDescriptorBaseTest: XCTestCase { + + var codeURI: String! = nil + let fileManager = FileManager.default + let functionName = MockDeploymentDescriptorBuilder.functionName + + override func setUpWithError() throws { + // create a fake lambda package zip file + let (_, tempFile) = try self.prepareTemporaryPackageFile() + self.codeURI = tempFile + } + + override func tearDownWithError() throws { + // delete the fake lambda package (silently ignore errors) + try self.deleteTemporaryPackageFile(self.codeURI) + self.codeURI = nil + } + + @discardableResult + func prepareTemporaryPackageFile() throws -> (String, String) { + let fm = FileManager.default + let tempDir = fm.temporaryDirectory + let packageDir = MockDeploymentDescriptorBuilder.packageDir() + let packageZip = MockDeploymentDescriptorBuilder.packageZip() + try fm.createDirectory(atPath: tempDir.path + packageDir, + withIntermediateDirectories: true) + let tempFile = tempDir.path + packageDir + packageZip + XCTAssertTrue(fm.createFile(atPath: tempFile, contents: nil)) + return (tempDir.path, tempFile) + } + + func deleteTemporaryPackageFile(_ file: String) { + let fm = FileManager.default + try? fm.removeItem(atPath: file) + } + + // expected YAML values are either + // Key: + // Key: Value + // - Value + enum Expected { + case keyOnly(indent: Int, key: String) + case keyValue(indent: Int, keyValue: [String: String]) + case arrayKey(indent: Int, key: String) +// case arrayKeyValue(indent: Int, key: [String:String]) + func string() -> [String] { + let indent: Int = YAMLEncoder.singleIndent + var value: [String] = [] + switch self { + case .keyOnly(let i, let k): + value = [String(repeating: " ", count: indent * i) + "\(k):"] + case .keyValue(let i, let kv): + value = kv.keys.map { String(repeating: " ", count: indent * i) + "\($0): \(kv[$0] ?? "")" } + case .arrayKey(let i, let k): + value = [String(repeating: " ", count: indent * i) + "- \(k)"] +// case .arrayKeyValue(let i, let kv): +// indent = i +// value = kv.keys.map { "- \($0): \(String(describing: kv[$0]))" }.joined(separator: "\n") + } + return value + } + } + + private func testDeploymentDescriptor(deployment: String, + expected: [Expected]) -> Bool { + + // given + let samYAML = deployment + + // then + let result = expected.allSatisfy { + // each string in the expected [] is present in the YAML + var result = true + $0.string().forEach { + result = result && samYAML.contains( $0 ) + } + return result + } + + if !result { + print("===========") + print(samYAML) + print("-----------") + print(expected.compactMap { $0.string().joined(separator: "\n") } .joined(separator: "\n")) + print("===========") + } + + return result + } + + func generateAndTestDeploymentDescriptor(deployment: T, + expected: [Expected]) -> Bool { + // when + let samYAML = deployment.toYAML() + + return testDeploymentDescriptor(deployment: samYAML, expected: expected) + } + + func generateAndTestDeploymentDescriptor(deployment: T, + expected: Expected) -> Bool { + return generateAndTestDeploymentDescriptor(deployment: deployment, expected: [expected]) + } + + func expectedSAMHeaders() -> [Expected] { + return [Expected.keyValue(indent: 0, + keyValue: [ + "Description": "A SAM template to deploy a Swift Lambda function", + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31"]) + ] + } + + func expectedFunction(architecture: String = "arm64") -> [Expected] { + return [ + Expected.keyOnly(indent: 0, key: "Resources"), + Expected.keyOnly(indent: 1, key: "TestLambda"), + Expected.keyValue(indent: 2, keyValue: ["Type": "AWS::Serverless::Function"]), + Expected.keyOnly(indent: 2, key: "Properties"), + Expected.keyValue(indent: 3, keyValue: [ + "Handler": "Provided", + "CodeUri": self.codeURI, + "Runtime": "provided.al2"]), + Expected.keyOnly(indent: 3, key: "Architectures"), + Expected.arrayKey(indent: 4, key: architecture) + ] + + } + + func expectedEnvironmentVariables() -> [Expected] { + return [ + Expected.keyOnly(indent: 3, key: "Environment"), + Expected.keyOnly(indent: 4, key: "Variables"), + Expected.keyValue(indent: 5, keyValue: ["NAME1": "VALUE1"]) + ] + } + + func expectedHttpAPi() -> [Expected] { + return [ + Expected.keyOnly(indent: 3, key: "Events"), + Expected.keyOnly(indent: 4, key: "HttpApiEvent"), + Expected.keyValue(indent: 5, keyValue: ["Type": "HttpApi"]) + + ] + } + + func expectedQueue() -> [Expected] { + return [ + Expected.keyOnly(indent: 0, key: "Resources"), + Expected.keyOnly(indent: 1, key: "QueueTestQueue"), + Expected.keyValue(indent: 2, keyValue: ["Type": "AWS::SQS::Queue"]), + Expected.keyOnly(indent: 2, key: "Properties"), + Expected.keyValue(indent: 3, keyValue: ["QueueName": "test-queue"]) + ] + } + + func expectedQueueEventSource(source: String) -> [Expected] { + return [ + Expected.keyOnly(indent: 3, key: "Events"), + Expected.keyOnly(indent: 4, key: "SQSEvent"), + Expected.keyValue(indent: 5, keyValue: ["Type": "SQS"]), + Expected.keyOnly(indent: 5, key: "Properties"), + Expected.keyValue(indent: 6, keyValue: ["Enabled": "true", + "BatchSize": "10"]), + Expected.keyOnly(indent: 6, key: "Queue"), + Expected.keyOnly(indent: 7, key: "Fn::GetAtt"), + Expected.arrayKey(indent: 8, key: source), + Expected.arrayKey(indent: 8, key: "Arn") + ] + } + + func expectedQueueEventSource(arn: String) -> [Expected] { + return [ + Expected.keyOnly(indent: 3, key: "Events"), + Expected.keyOnly(indent: 4, key: "SQSEvent"), + Expected.keyValue(indent: 5, keyValue: ["Type": "SQS"]), + Expected.keyOnly(indent: 5, key: "Properties"), + Expected.keyValue(indent: 6, keyValue: ["Enabled": "true", + "BatchSize": "10", + "Queue": arn]) + ] + } +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift new file mode 100644 index 00000000..5318351b --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -0,0 +1,443 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import XCTest +@testable import AWSLambdaDeploymentDescriptor + +// This test case tests the logic built into the DSL, +// i.e. the additional resources created automatically +// and the check on existence of the ZIP file +// the rest is boiler plate code +final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { + + //MARK: ServerlessFunction resource + func testGenericFunction() { + + // given + let expected: [Expected] = expectedSAMHeaders() + + expectedFunction() + + expectedEnvironmentVariables() + + expectedHttpAPi() + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + codeURI: self.codeURI, + eventSource: HttpApi().resource(), + environmentVariable: ["NAME1": "VALUE1"] + ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + + } + + func testQueueResource() { + + // given + let expected = expectedQueue() + + let queue = Queue(logicalName: "QueueTestQueue", physicalName: "test-queue") + + let testDeployment = MockDeploymentDescriptorBuilder(withResource: queue) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + // check wether the builder creates additional queue resources + func testLambdaCreateAdditionalResourceWithName() { + + // given + let expected = expectedQueue() + + let sqsEventSource = Sqs("test-queue").resource() + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + codeURI: self.codeURI, + eventSource: sqsEventSource, + environmentVariable: [:]) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + // check wether the builder creates additional queue resources + func testLambdaCreateAdditionalResourceWithQueue() { + + // given + let expected = expectedQueue() + + let sqsEventSource = Sqs(Queue(logicalName: "QueueTestQueue", + physicalName: "test-queue")).resource() + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + codeURI: self.codeURI, + eventSource: sqsEventSource, + environmentVariable: [:] ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + // check wether the builder detects missing ZIP package + func testLambdaMissingZIPPackage() { + + // when + let name = "TestFunction" + let codeUri = "/path/does/not/exist/lambda.zip" + + // then + XCTAssertThrowsError(try Function.packagePath(name: name, codeUri: codeUri)) + } + + // check wether the builder detects existing packages + func testLambdaExistingZIPPackage() throws { + + // given + XCTAssertNoThrow(try prepareTemporaryPackageFile()) + let (tempDir, tempFile) = try prepareTemporaryPackageFile() + let expected = Expected.keyValue(indent: 3, keyValue: ["CodeUri": tempFile]) + + CommandLine.arguments = ["test", "--archive-path", tempDir] + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + codeURI: self.codeURI, + eventSource: HttpApi().resource(), + environmentVariable: ["NAME1": "VALUE1"] ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + + // cleanup + XCTAssertNoThrow(try deleteTemporaryPackageFile(tempFile)) + } + + func testFunctionDescription() { + // given + let description = "My function description" + let expected = [Expected.keyValue(indent: 3, keyValue: ["Description": description])] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) { + description + } + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + func testFunctionAliasModifier() { + // given + let aliasName = "MyAlias" + let sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + let expected = [Expected.keyValue(indent: 3, keyValue: ["AutoPublishAliasAllProperties": "true", + "AutoPublishAlias": aliasName, + "AutoPublishCodeSha256" : sha256])] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) + .autoPublishAlias(aliasName, all: true) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + func testFunctionEphemeralStorageModifier() { + // given + let size = 1024 + let expected = [ + Expected.keyOnly(indent: 3, key: "EphemeralStorage"), + Expected.keyValue(indent: 4, keyValue: ["Size": "\(size)"]) + ] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) + .ephemeralStorage(size) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + func testeventInvokeConfigWithArn() { + // given + let validArn1 = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" + let validArn2 = "arn:aws:lambda:eu-central-1:012345678901:lambda-test" + let expected = [ + Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), + Expected.keyValue(indent: 4, keyValue: ["MaximumEventAgeInSeconds": "900", + "MaximumRetryAttempts": "3"]), + Expected.keyOnly(indent: 3, key: "DestinationConfig"), + Expected.keyOnly(indent: 4, key: "OnSuccess"), + Expected.keyValue(indent: 5, keyValue: ["Type": "SQS", + "Destination": validArn1]), + Expected.keyOnly(indent: 4, key: "OnFailure"), + Expected.keyValue(indent: 5, keyValue: ["Type": "Lambda", + "Destination": validArn2]) + ] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) + .eventInvoke(onSuccess: validArn1, + onFailure: validArn2, + maximumEventAgeInSeconds: 900, + maximumRetryAttempts: 3) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + func testeventInvokeConfigWithSuccessQueue() { + // given + let queue1 = Queue(logicalName: "queue1", physicalName: "queue1") + let expected = [ + Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), + Expected.keyValue(indent: 4, keyValue: ["MaximumEventAgeInSeconds": "900", + "MaximumRetryAttempts": "3"]), + Expected.keyOnly(indent: 4, key: "DestinationConfig"), + Expected.keyOnly(indent: 5, key: "OnSuccess"), + Expected.keyValue(indent: 6, keyValue: ["Type": "SQS"]), + Expected.keyOnly(indent: 6, key: "Destination"), + Expected.keyOnly(indent: 7, key: "Fn::GetAtt"), + Expected.arrayKey(indent: 8, key: "queue1"), + Expected.arrayKey(indent: 8, key: "Arn") + ] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) + .eventInvoke(onSuccess: queue1.resource()[0], + onFailure: nil, + maximumEventAgeInSeconds: 900, + maximumRetryAttempts: 3) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + + } + + func testeventInvokeConfigWithFailureQueue() { + // given + let queue1 = Queue(logicalName: "queue1", physicalName: "queue1") + let expected = [ + Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), + Expected.keyValue(indent: 4, keyValue: ["MaximumEventAgeInSeconds": "900", + "MaximumRetryAttempts": "3"]), + Expected.keyOnly(indent: 4, key: "DestinationConfig"), + Expected.keyOnly(indent: 5, key: "OnFailure"), + Expected.keyValue(indent: 6, keyValue: ["Type": "SQS"]), + Expected.keyOnly(indent: 6, key: "Destination"), + Expected.keyOnly(indent: 7, key: "Fn::GetAtt"), + Expected.arrayKey(indent: 8, key: "queue1"), + Expected.arrayKey(indent: 8, key: "Arn") + ] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) + .eventInvoke(onSuccess: nil, + onFailure: queue1.resource()[0], + maximumEventAgeInSeconds: 900, + maximumRetryAttempts: 3) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + + } + + func testeventInvokeConfigWithSuccessLambda() { + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), + Expected.keyValue(indent: 4, keyValue: ["MaximumEventAgeInSeconds": "900", + "MaximumRetryAttempts": "3"]), + Expected.keyOnly(indent: 4, key: "DestinationConfig"), + Expected.keyOnly(indent: 5, key: "OnSuccess"), + Expected.keyValue(indent: 6, keyValue: ["Type": "Lambda"]), + Expected.keyOnly(indent: 6, key: "Destination"), + Expected.keyOnly(indent: 7, key: "Fn::GetAtt"), + Expected.arrayKey(indent: 8, key: functionName), + Expected.arrayKey(indent: 8, key: "Arn") + ] + + // when + var function = Function(name: functionName, codeURI: self.codeURI) + let resource = function.resource() + XCTAssertTrue(resource.count == 1) + function = function.eventInvoke(onSuccess: resource[0], + onFailure: nil, + maximumEventAgeInSeconds: 900, + maximumRetryAttempts: 3) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + func testURLConfigCors() { + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "FunctionUrlConfig"), + Expected.keyValue(indent: 4, keyValue: ["AuthType" : "AWS_IAM"]), + Expected.keyValue(indent: 4, keyValue: ["InvokeMode" : "BUFFERED"]), + Expected.keyOnly(indent: 4, key: "Cors"), + Expected.keyValue(indent: 5, keyValue: ["MaxAge":"99", + "AllowCredentials" : "true"]), + Expected.keyOnly(indent: 5, key: "AllowHeaders"), + Expected.arrayKey(indent: 6, key: "header1"), + Expected.arrayKey(indent: 6, key: "header2"), + Expected.keyOnly(indent: 5, key: "AllowMethods"), + Expected.arrayKey(indent: 6, key: "GET"), + Expected.arrayKey(indent: 6, key: "POST"), + Expected.keyOnly(indent: 5, key: "AllowOrigins"), + Expected.arrayKey(indent: 6, key: "origin1"), + Expected.arrayKey(indent: 6, key: "origin2"), + Expected.keyOnly(indent: 5, key: "ExposeHeaders"), + Expected.arrayKey(indent: 6, key: "header1"), + Expected.arrayKey(indent: 6, key: "header2"), + ] + + // when + var function = Function(name: functionName, codeURI: self.codeURI) + let resource = function.resource() + XCTAssertTrue(resource.count == 1) + function = function.urlConfig(authType: .iam, + invokeMode: .buffered, + allowCredentials: true, + maxAge: 99) { + AllowHeaders { + "header1" + "header2" + } + AllowMethods { + HttpVerb.GET + HttpVerb.POST + } + AllowOrigins { + "origin1" + "origin2" + } + ExposeHeaders { + "header1" + "header2" + } + } + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + + } + + func testURLConfigNoCors() { + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "FunctionUrlConfig"), + Expected.keyValue(indent: 4, keyValue: ["AuthType" : "AWS_IAM"]), + Expected.keyValue(indent: 4, keyValue: ["InvokeMode" : "BUFFERED"]), + ] + + // when + var function = Function(name: functionName, codeURI: self.codeURI) + let resource = function.resource() + XCTAssertTrue(resource.count == 1) + function = function.urlConfig(authType: .iam, + invokeMode: .buffered) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + + } + + func testFileSystemConfig() { + // given + let validArn1 = "arn:aws:elasticfilesystem:eu-central-1:012345678901:access-point/fsap-abcdef01234567890" + let validArn2 = "arn:aws:elasticfilesystem:eu-central-1:012345678901:access-point/fsap-abcdef01234567890" + let mount1 = "/mnt/path1" + let mount2 = "/mnt/path2" + let expected = [ + Expected.keyOnly(indent: 3, key: "FileSystemConfigs"), + Expected.arrayKey(indent: 4, key: ""), + Expected.keyValue(indent: 5, keyValue: ["Arn":validArn1, + "LocalMountPath" : mount1]), + Expected.keyValue(indent: 5, keyValue: ["Arn":validArn2, + "LocalMountPath" : mount2]) + ] + + // when + let function = Function(name: functionName, codeURI: self.codeURI) + .fileSystem(validArn1, mountPoint: mount1) + .fileSystem(validArn2, mountPoint: mount2) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: function) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + //MARK: SimpleTable resource + func testSimpleTable() { + // given + let expected = [ + Expected.keyOnly(indent: 1, key: "SwiftLambdaTable"), + Expected.keyValue(indent: 2, keyValue: ["Type": "AWS::Serverless::SimpleTable"]), + Expected.keyValue(indent: 3, keyValue: ["TableName": "swift-lambda-table"]), + Expected.keyOnly(indent: 3, key: "PrimaryKey"), + Expected.keyValue(indent: 4, keyValue: ["Type": "String", "Name" : "id"]), + ] + + // when + let table = Table(logicalName: "SwiftLambdaTable", + physicalName: "swift-lambda-table", + primaryKeyName: "id", + primaryKeyType: "String") + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: table) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + + func testSimpleTableCapacityThroughput() { + // given + let writeCapacity = 999 + let readCapacity = 666 + let expected = [ + Expected.keyOnly(indent: 3, key: "ProvisionedThroughput"), + Expected.keyValue(indent: 4, keyValue: ["ReadCapacityUnits": "\(readCapacity)"]), + Expected.keyValue(indent: 4, keyValue: ["WriteCapacityUnits": "\(writeCapacity)"]) + ] + + // when + let table = Table(logicalName: "SwiftLambdaTable", + physicalName: "swift-lambda-table", + primaryKeyName: "id", + primaryKeyType: "String") + .provisionedThroughput(readCapacityUnits: readCapacity, writeCapacityUnits: writeCapacity) + + // then + let testDeployment = MockDeploymentDescriptorBuilder(withResource: table) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) + } + +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift new file mode 100644 index 00000000..0a07078f --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -0,0 +1,538 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +@testable import AWSLambdaDeploymentDescriptor +import XCTest + +// this test case tests the generation of the SAM deployment descriptor in JSON +final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { + + func testSAMHeader() { + + // given + let expected = expectedSAMHeaders() + + let testDeployment = MockDeploymentDescriptor(withFunction: false, codeURI: self.codeURI) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testLambdaFunctionResource() { + + // given + let expected = [expectedFunction(), expectedSAMHeaders()].flatMap { $0 } + + let testDeployment = MockDeploymentDescriptor(withFunction: true, codeURI: self.codeURI) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testLambdaFunctionWithSpecificArchitectures() { + + // given + let expected = [expectedFunction(architecture: ServerlessFunctionProperties.Architectures.x64.rawValue), + expectedSAMHeaders()] + .flatMap { $0 } + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: true, + architecture: .x64, + codeURI: self.codeURI) + + // then + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testAllFunctionProperties() { + + // given + let expected = [Expected.keyValue(indent: 3, + keyValue: ["AutoPublishAliasAllProperties": "true", + "AutoPublishAlias" : "alias", + "AutoPublishCodeSha256" : "sha256", + "Description" : "my function description" + ] ), + Expected.keyOnly(indent: 3, key: "EphemeralStorage"), + Expected.keyValue(indent: 4, keyValue: ["Size": "1024"]) + ] + + // when + var functionProperties = ServerlessFunctionProperties(codeUri: self.codeURI, architecture: .arm64) + functionProperties.autoPublishAliasAllProperties = true + functionProperties.autoPublishAlias = "alias" + functionProperties.autoPublishCodeSha256 = "sha256" + functionProperties.description = "my function description" + functionProperties.ephemeralStorage = ServerlessFunctionProperties.EphemeralStorage(1024) + let functionToTest = Resource(type: .function, + properties: functionProperties, + name: functionName) + + // then + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ functionToTest ]) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testEventInvokeConfig() { + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), + Expected.keyOnly(indent: 4, key: "DestinationConfig"), + Expected.keyOnly(indent: 5, key: "OnSuccess"), + Expected.keyValue(indent: 6, keyValue: ["Type" : "SNS"]), + Expected.keyOnly(indent: 5, key: "OnFailure"), + Expected.keyValue(indent: 6, keyValue: ["Destination" : "arn:aws:sqs:eu-central-1:012345678901:lambda-test"]), + Expected.keyValue(indent: 6, keyValue: ["Type" : "Lambda"]) + ] + + // when + var functionProperties = ServerlessFunctionProperties(codeUri: self.codeURI, architecture: .arm64) + let validArn = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" + let arn = Arn(validArn) + let destination1 = ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestination(destination: nil, + type: .sns) + let destination2 = ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestination(destination: .arn(arn!), + type: .lambda) + let destinations = ServerlessFunctionProperties.EventInvokeConfiguration.EventInvokeDestinationConfiguration( + onSuccess: destination1, + onFailure: destination2) + + let invokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( + destinationConfig: destinations, + maximumEventAgeInSeconds: 999, + maximumRetryAttempts: 33) + functionProperties.eventInvokeConfig = invokeConfig + let functionToTest = Resource(type: .function, + properties: functionProperties, + name: functionName) + + // then + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ functionToTest ]) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + + } + + func testFileSystemConfig() { + // given + let validArn = "arn:aws:elasticfilesystem:eu-central-1:012345678901:access-point/fsap-abcdef01234567890" + let mount1 = "/mnt/path1" + let mount2 = "/mnt/path2" + let expected = [ + Expected.keyOnly(indent: 3, key: "FileSystemConfigs"), + Expected.arrayKey(indent: 4, key: ""), + Expected.keyValue(indent: 5, keyValue: ["Arn":validArn, + "LocalMountPath" : mount1]), + Expected.keyValue(indent: 5, keyValue: ["Arn":validArn, + "LocalMountPath" : mount2]) + ] + + // when + var functionProperties = ServerlessFunctionProperties(codeUri: self.codeURI, architecture: .arm64) + + if let fileSystemConfig1 = ServerlessFunctionProperties.FileSystemConfig(arn: validArn, localMountPath: mount1), + let fileSystemConfig2 = ServerlessFunctionProperties.FileSystemConfig(arn: validArn, localMountPath: mount2) { + functionProperties.fileSystemConfigs = [fileSystemConfig1, fileSystemConfig2] + } else { + XCTFail("Invalid Arn or MountPoint") + } + + let functionToTest = Resource(type: .function, + properties: functionProperties, + name: functionName) + + // then + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ functionToTest ]) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testInvalidFileSystemConfig() { + // given + let validArn = "arn:aws:elasticfilesystem:eu-central-1:012345678901:access-point/fsap-abcdef01234567890" + let invalidArn1 = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" + let invalidArn2 = "arn:aws:elasticfilesystem:eu-central-1:012345678901:access-point/fsap-abcdef01234" + + // when + // mount path is not conform (should be /mnt/something) + let fileSystemConfig1 = ServerlessFunctionProperties.FileSystemConfig(arn: validArn, localMountPath: "/mnt1") + // arn is not conform (should be an elastic filesystem) + let fileSystemConfig2 = ServerlessFunctionProperties.FileSystemConfig(arn: invalidArn1, localMountPath: "/mnt/path1") + // arn is not conform (should have 17 digits in the ID) + let fileSystemConfig3 = ServerlessFunctionProperties.FileSystemConfig(arn: invalidArn2, localMountPath: "/mnt/path1") + // OK + let fileSystemConfig4 = ServerlessFunctionProperties.FileSystemConfig(arn: validArn, localMountPath: "/mnt/path1") + + // then + XCTAssertNil(fileSystemConfig1) + XCTAssertNil(fileSystemConfig2) + XCTAssertNil(fileSystemConfig3) + XCTAssertNotNil(fileSystemConfig4) + } + + func testURLConfig() { + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "FunctionUrlConfig"), + Expected.keyValue(indent: 4, keyValue: ["AuthType" : "AWS_IAM"]), + Expected.keyValue(indent: 4, keyValue: ["InvokeMode" : "BUFFERED"]), + Expected.keyOnly(indent: 4, key: "Cors"), + Expected.keyValue(indent: 5, keyValue: ["MaxAge":"99", + "AllowCredentials" : "true"]), + Expected.keyOnly(indent: 5, key: "AllowHeaders"), + Expected.arrayKey(indent: 6, key: "allowHeaders"), + Expected.keyOnly(indent: 5, key: "AllowMethods"), + Expected.arrayKey(indent: 6, key: "allowMethod"), + Expected.keyOnly(indent: 5, key: "AllowOrigins"), + Expected.arrayKey(indent: 6, key: "allowOrigin"), + Expected.keyOnly(indent: 5, key: "ExposeHeaders"), + Expected.arrayKey(indent: 6, key: "exposeHeaders") + ] + + // when + var functionProperties = ServerlessFunctionProperties(codeUri: self.codeURI, architecture: .arm64) + + let cors = ServerlessFunctionProperties.URLConfig.Cors(allowCredentials: true, + allowHeaders: ["allowHeaders"], + allowMethods: ["allowMethod"], + allowOrigins: ["allowOrigin"], + exposeHeaders: ["exposeHeaders"], + maxAge: 99) + let config = ServerlessFunctionProperties.URLConfig(authType: .iam, + cors: cors, + invokeMode: .buffered) + functionProperties.functionUrlConfig = config + + let functionToTest = Resource(type: .function, + properties: functionProperties, + name: functionName) + + // then + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ functionToTest ]) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testSimpleTableResource() { + + // given + let expected = [ + Expected.keyOnly(indent: 0, key: "Resources"), + Expected.keyOnly(indent: 1, key: "LogicalTestTable"), + Expected.keyValue(indent: 2, keyValue: ["Type": "AWS::Serverless::SimpleTable"]), + Expected.keyOnly(indent: 2, key: "Properties"), + Expected.keyOnly(indent: 3, key: "PrimaryKey"), + Expected.keyValue(indent: 3, keyValue: ["TableName": "TestTable"]), + Expected.keyValue(indent: 4, keyValue: ["Name": "pk", + "Type": "String"]) + ] + + let pk = SimpleTableProperties.PrimaryKey(name: "pk", type: "String") + let props = SimpleTableProperties(primaryKey: pk, tableName: "TestTable") + let table = Resource(type: .table, + properties: props, + name: "LogicalTestTable") + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ table ] + ) + + // then + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testSQSQueueResource() { + + // given + let expected = expectedQueue() + + let props = SQSResourceProperties(queueName: "test-queue") + let queue = Resource(type: .queue, + properties: props, + name: "QueueTestQueue") + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ queue ] + + ) + + // test + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testHttpApiEventSourceCatchAll() { + + // given + let expected = expectedSAMHeaders() + + expectedFunction(architecture: ServerlessFunctionProperties.Architectures.defaultArchitecture().rawValue) + + [ + Expected.keyOnly(indent: 4, key: "HttpApiEvent"), + Expected.keyValue(indent: 5, keyValue: ["Type": "HttpApi"]) + ] + + let httpApi = Resource( + type: .httpApi, + properties: nil, + name: "HttpApiEvent") + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + eventSource: [ httpApi ] ) + + // then + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testHttpApiEventSourceSpecific() { + + // given + let expected = expectedSAMHeaders() + + expectedFunction(architecture: ServerlessFunctionProperties.Architectures.defaultArchitecture().rawValue) + + [ + Expected.keyOnly(indent: 4, key: "HttpApiEvent"), + Expected.keyValue(indent: 5, keyValue: ["Type": "HttpApi"]), + Expected.keyOnly(indent: 5, key: "Properties"), + Expected.keyValue(indent: 6, keyValue: ["Path": "/test", + "Method": "GET"]) + ] + + let props = HttpApiProperties(method: .GET, path: "/test") + let httpApi = Resource( + type: .httpApi, + properties: props, + name: "HttpApiEvent") + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + eventSource: [ httpApi ]) + + // then + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testSQSEventSourceWithArn() { + + let name = #"arn:aws:sqs:eu-central-1:012345678901:lambda-test"# + // given + let expected = expectedSAMHeaders() + + expectedFunction() + + expectedQueueEventSource(arn: name) + + let props = SQSEventProperties(byRef: name, + batchSize: 10, + enabled: true) + let queue = Resource(type: .sqs, + properties: props, + name: "SQSEvent") + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + eventSource: [ queue ] ) + + // then + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testSQSEventSourceWithoutArn() { + + // given + let expected = expectedSAMHeaders() + + expectedFunction() + + expectedQueueEventSource(source: "QueueQueueLambdaTest") + + let props = SQSEventProperties(byRef: "queue-lambda-test", + batchSize: 10, + enabled: true) + let queue = Resource(type: .sqs, + properties: props, + name: "SQSEvent") + + // when + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + eventSource: [ queue ] ) + + // then + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testEnvironmentVariablesString() { + + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "Environment"), + Expected.keyOnly(indent: 4, key: "Variables"), + Expected.keyValue(indent: 5, keyValue: [ + "TEST2_VAR": "TEST2_VALUE", + "TEST1_VAR": "TEST1_VALUE" + ]) + ] + + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + environmentVariable: SAMEnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", + "TEST2_VAR": "TEST2_VALUE"]) ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + + } + + func testEnvironmentVariablesArray() { + + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "Environment"), + Expected.keyOnly(indent: 4, key: "Variables"), + Expected.keyOnly(indent: 5, key: "TEST1_VAR"), + Expected.keyValue(indent: 6, keyValue: ["Ref": "TEST1_VALUE"]) + ] + + var envVar = SAMEnvironmentVariable() + envVar.append("TEST1_VAR", ["Ref": "TEST1_VALUE"]) + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + environmentVariable: envVar ) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testEnvironmentVariablesDictionary() { + + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "Environment"), + Expected.keyOnly(indent: 4, key: "Variables"), + Expected.keyOnly(indent: 5, key: "TEST1_VAR"), + Expected.keyOnly(indent: 6, key: "Fn::GetAtt"), + Expected.arrayKey(indent: 7, key: "TEST1_VALUE"), + Expected.arrayKey(indent: 7, key: "Arn") + ] + + var envVar = SAMEnvironmentVariable() + envVar.append("TEST1_VAR", ["Fn::GetAtt": ["TEST1_VALUE", "Arn"]]) + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + environmentVariable: envVar ) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testEnvironmentVariablesResource() { + + // given + let expected = [ + Expected.keyOnly(indent: 3, key: "Environment"), + Expected.keyOnly(indent: 4, key: "Variables"), + Expected.keyOnly(indent: 5, key: "TEST1_VAR"), + Expected.keyValue(indent: 6, keyValue: ["Ref": "LogicalName"]) + ] + + let props = SQSResourceProperties(queueName: "PhysicalName") + let resource = Resource(type: .queue, properties: props, name: "LogicalName") + var envVar = SAMEnvironmentVariable() + envVar.append("TEST1_VAR", resource) + let testDeployment = MockDeploymentDescriptor(withFunction: true, + codeURI: self.codeURI, + environmentVariable: envVar ) + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + func testEncodeArn() throws { + // given + let validArn = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" + + // when + let arn = Arn(validArn) + let yaml = try YAMLEncoder().encode(arn) + + // then + XCTAssertEqual(String(data: yaml, encoding: .utf8), arn?.arn) + } + + func testArnOK() { + // given + let validArn = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" + + // when + let arn = Arn(validArn) + + // then + XCTAssertNotNil(arn) + } + + func testArnFail() { + // given + let invalidArn = "invalid" + + // when + let arn = Arn(invalidArn) + + // then + XCTAssertNil(arn) + } + + func testServicefromArn() { + // given + var validArn = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" + + // when + var arn = Arn(validArn) + + // then + XCTAssertEqual("sqs", arn!.service()) + + // given + validArn = "arn:aws:lambda:eu-central-1:012345678901:lambda-test" + + // when + arn = Arn(validArn) + + // then + XCTAssertEqual("lambda", arn!.service()) + + // given + validArn = "arn:aws:event-bridge:eu-central-1:012345678901:lambda-test" + + // when + arn = Arn(validArn) + + // then + XCTAssertEqual("event-bridge", arn!.service()) + } + +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/FileDigestTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/FileDigestTests.swift new file mode 100644 index 00000000..dbb8cefb --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/FileDigestTests.swift @@ -0,0 +1,40 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import XCTest +import CryptoKit +@testable import AWSLambdaDeploymentDescriptor + +final class FileDigestTests: XCTestCase { + + + func testFileDigest() throws { + + let expected = "4a5d82d7a7a76a1487fb12ae7f1c803208b6b5e1cfb9ae14afdc0916301e3415" + let tempDir = FileManager.default.temporaryDirectory.path + let tempFile = "\(tempDir)/temp.txt" + let data = "Hello Digest World".data(using: .utf8) + FileManager.default.createFile(atPath: tempFile, contents: data) + defer { + try? FileManager.default.removeItem(atPath: tempFile) + } + + if let result = FileDigest.hex(from: tempFile) { + XCTAssertEqual(result, expected) + } else { + XCTFail("digest is nil") + } + } + +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift new file mode 100644 index 00000000..5ec56b40 --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -0,0 +1,121 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +import Foundation +import XCTest +@testable import AWSLambdaDeploymentDescriptor + +protocol MockDeploymentDescriptorBehavior { + func toJSON() -> String + func toYAML() -> String +} + +struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { + + let deploymentDescriptor: SAMDeploymentDescriptor + + init(withFunction: Bool = true, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String = "", + eventSource: [Resource]? = nil, + environmentVariable: SAMEnvironmentVariable? = nil, + additionalResources: [Resource] = []) { + if withFunction { + + let properties = ServerlessFunctionProperties( + codeUri: codeURI, + architecture: architecture, + eventSources: eventSource ?? [], + environment: environmentVariable ?? SAMEnvironmentVariable.none) + let serverlessFunction = Resource( + type: .function, + properties: properties, + name: "TestLambda") + + self.deploymentDescriptor = SAMDeploymentDescriptor( + description: "A SAM template to deploy a Swift Lambda function", + resources: [ serverlessFunction ] + additionalResources + + ) + } else { + self.deploymentDescriptor = SAMDeploymentDescriptor( + description: "A SAM template to deploy a Swift Lambda function", + resources: additionalResources + ) + } + } + func toJSON() -> String { + return self.deploymentDescriptor.toJSON(pretty: false) + } + func toYAML() -> String { + return self.deploymentDescriptor.toYAML() + } +} + +struct MockDeploymentDescriptorBuilder: MockDeploymentDescriptorBehavior { + + static let functionName = "TestLambda" + let deploymentDescriptor: DeploymentDescriptor + + init(withResource resource: any BuilderResource) { + + self.deploymentDescriptor = DeploymentDescriptor { + "A SAM template to deploy a Swift Lambda function" + resource + } + } + + init(withFunction: Bool = true, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String, + eventSource: Resource, + environmentVariable: [String: String]) { + if withFunction { + + self.deploymentDescriptor = DeploymentDescriptor { + "A SAM template to deploy a Swift Lambda function" + + Function(name: MockDeploymentDescriptorBuilder.functionName, + architecture: architecture, + codeURI: codeURI) { + EventSources { + eventSource + } + EnvironmentVariables { + environmentVariable + } + } + } + + } else { + self.deploymentDescriptor = DeploymentDescriptor { + "A SAM template to deploy a Swift Lambda function" + } + } + } + + func toJSON() -> String { + return self.deploymentDescriptor.samDeploymentDescriptor.toJSON(pretty: false) + } + func toYAML() -> String { + return self.deploymentDescriptor.samDeploymentDescriptor.toYAML() + } + + static func packageDir() -> String { + return "/\(functionName)" + } + static func packageZip() -> String { + return "/\(functionName).zip" + } +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift new file mode 100644 index 00000000..94aa5234 --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift @@ -0,0 +1,1402 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// + +// This is based on Foundation's TestJSONEncoder +// https://github.com/apple/swift-corelibs-foundation/blob/main/Tests/Foundation/Tests/TestJSONEncoder.swift + +import XCTest + +@testable import AWSLambdaDeploymentDescriptor + +struct TopLevelObjectWrapper: Codable, Equatable { + var value: T + + static func == (lhs: TopLevelObjectWrapper, rhs: TopLevelObjectWrapper) -> Bool { + return lhs.value == rhs.value + } + + init(_ value: T) { + self.value = value + } +} + +class TestYAMLEncoder: XCTestCase { + + // MARK: - Encoding Top-Level fragments + func test_encodingTopLevelFragments() { + + func _testFragment(value: T, fragment: String) { + let data: Data + let payload: String + + do { + data = try YAMLEncoder().encode(value) + payload = try XCTUnwrap(String.init(decoding: data, as: UTF8.self)) + XCTAssertEqual(fragment, payload) + } catch { + XCTFail("Failed to encode \(T.self) to YAML: \(error)") + return + } + } + + _testFragment(value: 2, fragment: "2") + _testFragment(value: false, fragment: "false") + _testFragment(value: true, fragment: "true") + _testFragment(value: Float(1), fragment: "1") + _testFragment(value: Double(2), fragment: "2") + _testFragment( + value: Decimal(Double(Float.leastNormalMagnitude)), + fragment: "0.000000000000000000000000000000000000011754943508222875648") + _testFragment(value: "test", fragment: "test") + let v: Int? = nil + _testFragment(value: v, fragment: "null") + } + + // MARK: - Encoding Top-Level Empty Types + func test_encodingTopLevelEmptyStruct() { + let empty = EmptyStruct() + _testRoundTrip(of: empty, expectedYAML: _yamlEmptyDictionary) + } + + func test_encodingTopLevelEmptyClass() { + let empty = EmptyClass() + _testRoundTrip(of: empty, expectedYAML: _yamlEmptyDictionary) + } + + // MARK: - Encoding Top-Level Single-Value Types + func test_encodingTopLevelSingleValueEnum() { + _testRoundTrip(of: Switch.off) + _testRoundTrip(of: Switch.on) + + _testRoundTrip(of: TopLevelArrayWrapper(Switch.off)) + _testRoundTrip(of: TopLevelArrayWrapper(Switch.on)) + } + + func test_encodingTopLevelSingleValueStruct() { + _testRoundTrip(of: Timestamp(3_141_592_653)) + _testRoundTrip(of: TopLevelArrayWrapper(Timestamp(3_141_592_653))) + } + + func test_encodingTopLevelSingleValueClass() { + _testRoundTrip(of: Counter()) + _testRoundTrip(of: TopLevelArrayWrapper(Counter())) + } + + // MARK: - Encoding Top-Level Structured Types + func test_encodingTopLevelStructuredStruct() { + // Address is a struct type with multiple fields. + let address = Address.testValue + _testRoundTrip(of: address) + } + + func test_encodingTopLevelStructuredClass() { + // Person is a class with multiple fields. + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + } + + func test_encodingTopLevelStructuredSingleStruct() { + // Numbers is a struct which encodes as an array through a single value container. + let numbers = Numbers.testValue + _testRoundTrip(of: numbers) + } + + func test_encodingTopLevelStructuredSingleClass() { + // Mapping is a class which encodes as a dictionary through a single value container. + let mapping = Mapping.testValue + _testRoundTrip(of: mapping) + } + + func test_encodingTopLevelDeepStructuredType() { + // Company is a type with fields which are Codable themselves. + let company = Company.testValue + _testRoundTrip(of: company) + } + + // MARK: - Output Formatting Tests + func test_encodingOutputFormattingDefault() { + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + } + + func test_encodingOutputFormattingPrettyPrinted() throws { + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + + let encoder = YAMLEncoder() + encoder.outputFormatting = [.sortedKeys] + + let emptyArray: [Int] = [] + let arrayOutput = try encoder.encode(emptyArray) + XCTAssertEqual(String.init(decoding: arrayOutput, as: UTF8.self), "") + + let emptyDictionary: [String: Int] = [:] + let dictionaryOutput = try encoder.encode(emptyDictionary) + XCTAssertEqual(String.init(decoding: dictionaryOutput, as: UTF8.self), "") + + struct DataType: Encodable { + let array = [1, 2, 3] + let dictionary: [String: Int] = [:] + let emptyAray: [Int] = [] + let secondArray: [Int] = [4, 5, 6] + let secondDictionary: [String: Int] = ["one": 1, "two": 2, "three": 3] + let singleElement: [Int] = [1] + let subArray: [String: [Int]] = ["array": []] + let subDictionary: [String: [String: Int]] = ["dictionary": [:]] + } + + let dataOutput = try encoder.encode([DataType(), DataType()]) + XCTAssertEqual( + String.init(decoding: dataOutput, as: UTF8.self), + """ + + - + array: + - 1 + - 2 + - 3 + dictionary: + emptyAray: + secondArray: + - 4 + - 5 + - 6 + secondDictionary: + one: 1 + three: 3 + two: 2 + singleElement: + - 1 + subArray: + array: + subDictionary: + dictionary: + - + array: + - 1 + - 2 + - 3 + dictionary: + emptyAray: + secondArray: + - 4 + - 5 + - 6 + secondDictionary: + one: 1 + three: 3 + two: 2 + singleElement: + - 1 + subArray: + array: + subDictionary: + dictionary: + """) + } + + func test_encodingOutputFormattingSortedKeys() { + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + } + + func test_encodingOutputFormattingPrettyPrintedSortedKeys() { + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + } + + // MARK: - Date Strategy Tests + func test_encodingDate() { + // We can't encode a top-level Date, so it'll be wrapped in an array. + _testRoundTrip(of: TopLevelArrayWrapper(Date())) + } + + func test_encodingDateSecondsSince1970() { + let seconds = 1000.0 + let expectedYAML = ["1000"] + + let d = Date(timeIntervalSince1970: seconds) + _testRoundTrip( + of: d, + expectedYAML: expectedYAML, + dateEncodingStrategy: .secondsSince1970) + } + + func test_encodingDateMillisecondsSince1970() { + let seconds = 1000.0 + let expectedYAML = ["1000000"] + + _testRoundTrip( + of: Date(timeIntervalSince1970: seconds), + expectedYAML: expectedYAML, + dateEncodingStrategy: .millisecondsSince1970) + } + + func test_encodingDateISO8601() { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = .withInternetDateTime + + let timestamp = Date(timeIntervalSince1970: 1000) + let expectedYAML = ["\(formatter.string(from: timestamp))"] + + // We can't encode a top-level Date, so it'll be wrapped in an array. + _testRoundTrip( + of: TopLevelArrayWrapper(timestamp), + expectedYAML: expectedYAML, + dateEncodingStrategy: .iso8601) + + } + + func test_encodingDateFormatted() { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .full + + let timestamp = Date(timeIntervalSince1970: 1000) + let expectedYAML = ["\(formatter.string(from: timestamp))"] + + // We can't encode a top-level Date, so it'll be wrapped in an array. + _testRoundTrip( + of: TopLevelArrayWrapper(timestamp), + expectedYAML: expectedYAML, + dateEncodingStrategy: .formatted(formatter)) + } + + func test_encodingDateCustom() { + let timestamp = Date() + + // We'll encode a number instead of a date. + let encode = { (_ data: Date, _ encoder: Encoder) throws -> Void in + var container = encoder.singleValueContainer() + try container.encode(42) + } + // let decode = { (_: Decoder) throws -> Date in return timestamp } + + // We can't encode a top-level Date, so it'll be wrapped in an array. + let expectedYAML = ["42"] + _testRoundTrip( + of: TopLevelArrayWrapper(timestamp), + expectedYAML: expectedYAML, + dateEncodingStrategy: .custom(encode)) + } + + func test_encodingDateCustomEmpty() { + let timestamp = Date() + + // Encoding nothing should encode an empty keyed container ({}). + let encode = { (_: Date, _: Encoder) throws -> Void in } + // let decode = { (_: Decoder) throws -> Date in return timestamp } + + // We can't encode a top-level Date, so it'll be wrapped in an array. + let expectedYAML = [""] + _testRoundTrip( + of: TopLevelArrayWrapper(timestamp), + expectedYAML: expectedYAML, + dateEncodingStrategy: .custom(encode)) + } + + // MARK: - Data Strategy Tests + func test_encodingBase64Data() { + let data = Data([0xDE, 0xAD, 0xBE, 0xEF]) + + // We can't encode a top-level Data, so it'll be wrapped in an array. + let expectedYAML = ["3q2+7w=="] + _testRoundTrip(of: TopLevelArrayWrapper(data), expectedYAML: expectedYAML) + } + + func test_encodingCustomData() { + // We'll encode a number instead of data. + let encode = { (_ data: Data, _ encoder: Encoder) throws -> Void in + var container = encoder.singleValueContainer() + try container.encode(42) + } + // let decode = { (_: Decoder) throws -> Data in return Data() } + + // We can't encode a top-level Data, so it'll be wrapped in an array. + let expectedYAML = ["42"] + _testRoundTrip( + of: TopLevelArrayWrapper(Data()), + expectedYAML: expectedYAML, + dataEncodingStrategy: .custom(encode)) + } + + func test_encodingCustomDataEmpty() { + // Encoding nothing should encode an empty keyed container ({}). + let encode = { (_: Data, _: Encoder) throws -> Void in } + // let decode = { (_: Decoder) throws -> Data in return Data() } + + // We can't encode a top-level Data, so it'll be wrapped in an array. + let expectedYAML = [""] + _testRoundTrip( + of: TopLevelArrayWrapper(Data()), + expectedYAML: expectedYAML, + dataEncodingStrategy: .custom(encode)) + } + + // MARK: - Non-Conforming Floating Point Strategy Tests + func test_encodingNonConformingFloats() { + _testEncodeFailure(of: TopLevelArrayWrapper(Float.infinity)) + _testEncodeFailure(of: TopLevelArrayWrapper(-Float.infinity)) + _testEncodeFailure(of: TopLevelArrayWrapper(Float.nan)) + + _testEncodeFailure(of: TopLevelArrayWrapper(Double.infinity)) + _testEncodeFailure(of: TopLevelArrayWrapper(-Double.infinity)) + _testEncodeFailure(of: TopLevelArrayWrapper(Double.nan)) + } + + func test_encodingNonConformingFloatStrings() { + let encodingStrategy: YAMLEncoder.NonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "INF", negativeInfinity: "-INF", nan: "NaN") + // let decodingStrategy: YAMLDecoder.NonConformingFloatDecodingStrategy = .convertFromString(positiveInfinity: "INF", negativeInfinity: "-INF", nan: "NaN") + + _testRoundTrip( + of: TopLevelArrayWrapper(Float.infinity), + expectedYAML: ["INF"], + nonConformingFloatEncodingStrategy: encodingStrategy) + _testRoundTrip( + of: TopLevelArrayWrapper(-Float.infinity), + expectedYAML: ["-INF"], + nonConformingFloatEncodingStrategy: encodingStrategy) + + // Since Float.nan != Float.nan, we have to use a placeholder that'll encode NaN but actually round-trip. + _testRoundTrip( + of: TopLevelArrayWrapper(FloatNaNPlaceholder()), + expectedYAML: ["NaN"], + nonConformingFloatEncodingStrategy: encodingStrategy) + + _testRoundTrip( + of: TopLevelArrayWrapper(Double.infinity), + expectedYAML: ["INF"], + nonConformingFloatEncodingStrategy: encodingStrategy) + _testRoundTrip( + of: TopLevelArrayWrapper(-Double.infinity), + expectedYAML: ["-INF"], + nonConformingFloatEncodingStrategy: encodingStrategy) + + // Since Double.nan != Double.nan, we have to use a placeholder that'll encode NaN but actually round-trip. + _testRoundTrip( + of: TopLevelArrayWrapper(DoubleNaNPlaceholder()), + expectedYAML: ["NaN"], + nonConformingFloatEncodingStrategy: encodingStrategy) + } + + // MARK: - Encoder Features + func test_nestedContainerCodingPaths() { + let encoder = YAMLEncoder() + do { + _ = try encoder.encode(NestedContainersTestType()) + } catch { + XCTFail("Caught error during encoding nested container types: \(error)") + } + } + + func test_superEncoderCodingPaths() { + let encoder = YAMLEncoder() + do { + _ = try encoder.encode(NestedContainersTestType(testSuperEncoder: true)) + } catch { + XCTFail("Caught error during encoding nested container types: \(error)") + } + } + + func test_notFoundSuperDecoder() { + struct NotFoundSuperDecoderTestType: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + _ = try container.superDecoder(forKey: .superDecoder) + } + + private enum CodingKeys: String, CodingKey { + case superDecoder = "super" + } + } + // let decoder = YAMLDecoder() + // do { + // let _ = try decoder.decode(NotFoundSuperDecoderTestType.self, from: Data(#"{}"#.utf8)) + // } catch { + // XCTFail("Caught error during decoding empty super decoder: \(error)") + // } + } + + // MARK: - Test encoding and decoding of built-in Codable types + func test_codingOfBool() { + test_codingOf(value: Bool(true), toAndFrom: "true") + test_codingOf(value: Bool(false), toAndFrom: "false") + + // do { + // _ = try YAMLDecoder().decode([Bool].self, from: "[1]".data(using: .utf8)!) + // XCTFail("Coercing non-boolean numbers into Bools was expected to fail") + // } catch { } + + // Check that a Bool false or true isn't converted to 0 or 1 + // struct Foo: Decodable { + // var intValue: Int? + // var int8Value: Int8? + // var int16Value: Int16? + // var int32Value: Int32? + // var int64Value: Int64? + // var uintValue: UInt? + // var uint8Value: UInt8? + // var uint16Value: UInt16? + // var uint32Value: UInt32? + // var uint64Value: UInt64? + // var floatValue: Float? + // var doubleValue: Double? + // var decimalValue: Decimal? + // let boolValue: Bool + // } + + // func testValue(_ valueName: String) { + // do { + // let jsonData = "{ \"\(valueName)\": false }".data(using: .utf8)! + // _ = try YAMLDecoder().decode(Foo.self, from: jsonData) + // XCTFail("Decoded 'false' as non Bool for \(valueName)") + // } catch {} + // do { + // let jsonData = "{ \"\(valueName)\": true }".data(using: .utf8)! + // _ = try YAMLDecoder().decode(Foo.self, from: jsonData) + // XCTFail("Decoded 'true' as non Bool for \(valueName)") + // } catch {} + // } + + // testValue("intValue") + // testValue("int8Value") + // testValue("int16Value") + // testValue("int32Value") + // testValue("int64Value") + // testValue("uintValue") + // testValue("uint8Value") + // testValue("uint16Value") + // testValue("uint32Value") + // testValue("uint64Value") + // testValue("floatValue") + // testValue("doubleValue") + // testValue("decimalValue") + // let falseJsonData = "{ \"boolValue\": false }".data(using: .utf8)! + // if let falseFoo = try? YAMLDecoder().decode(Foo.self, from: falseJsonData) { + // XCTAssertFalse(falseFoo.boolValue) + // } else { + // XCTFail("Could not decode 'false' as a Bool") + // } + + // let trueJsonData = "{ \"boolValue\": true }".data(using: .utf8)! + // if let trueFoo = try? YAMLDecoder().decode(Foo.self, from: trueJsonData) { + // XCTAssertTrue(trueFoo.boolValue) + // } else { + // XCTFail("Could not decode 'true' as a Bool") + // } + } + + func test_codingOfNil() { + let x: Int? = nil + test_codingOf(value: x, toAndFrom: "null") + } + + func test_codingOfInt8() { + test_codingOf(value: Int8(-42), toAndFrom: "-42") + } + + func test_codingOfUInt8() { + test_codingOf(value: UInt8(42), toAndFrom: "42") + } + + func test_codingOfInt16() { + test_codingOf(value: Int16(-30042), toAndFrom: "-30042") + } + + func test_codingOfUInt16() { + test_codingOf(value: UInt16(30042), toAndFrom: "30042") + } + + func test_codingOfInt32() { + test_codingOf(value: Int32(-2_000_000_042), toAndFrom: "-2000000042") + } + + func test_codingOfUInt32() { + test_codingOf(value: UInt32(2_000_000_042), toAndFrom: "2000000042") + } + + func test_codingOfInt64() { + #if !arch(arm) + test_codingOf(value: Int64(-9_000_000_000_000_000_042), toAndFrom: "-9000000000000000042") + #endif + } + + func test_codingOfUInt64() { + #if !arch(arm) + test_codingOf(value: UInt64(9_000_000_000_000_000_042), toAndFrom: "9000000000000000042") + #endif + } + + func test_codingOfInt() { + let intSize = MemoryLayout.size + switch intSize { + case 4: // 32-bit + test_codingOf(value: Int(-2_000_000_042), toAndFrom: "-2000000042") + case 8: // 64-bit + #if arch(arm) + break + #else + test_codingOf(value: Int(-9_000_000_000_000_000_042), toAndFrom: "-9000000000000000042") + #endif + default: + XCTFail("Unexpected UInt size: \(intSize)") + } + } + + func test_codingOfUInt() { + let uintSize = MemoryLayout.size + switch uintSize { + case 4: // 32-bit + test_codingOf(value: UInt(2_000_000_042), toAndFrom: "2000000042") + case 8: // 64-bit + #if arch(arm) + break + #else + test_codingOf(value: UInt(9_000_000_000_000_000_042), toAndFrom: "9000000000000000042") + #endif + default: + XCTFail("Unexpected UInt size: \(uintSize)") + } + } + + func test_codingOfFloat() { + test_codingOf(value: Float(1.5), toAndFrom: "1.5") + + // Check value too large fails to decode. + // XCTAssertThrowsError(try YAMLDecoder().decode(Float.self, from: "1e100".data(using: .utf8)!)) + } + + func test_codingOfDouble() { + test_codingOf(value: Double(1.5), toAndFrom: "1.5") + + // Check value too large fails to decode. + // XCTAssertThrowsError(try YAMLDecoder().decode(Double.self, from: "100e323".data(using: .utf8)!)) + } + + func test_codingOfDecimal() { + test_codingOf(value: Decimal.pi, toAndFrom: "3.14159265358979323846264338327950288419") + + // Check value too large fails to decode. + // XCTAssertThrowsError(try YAMLDecoder().decode(Decimal.self, from: "100e200".data(using: .utf8)!)) + } + + func test_codingOfString() { + test_codingOf(value: "Hello, world!", toAndFrom: "Hello, world!") + } + + func test_codingOfURL() { + test_codingOf(value: URL(string: "https://swift.org")!, toAndFrom: "https://swift.org") + } + + // UInt and Int + func test_codingOfUIntMinMax() { + + struct MyValue: Codable, Equatable { + var int64Min = Int64.min + var int64Max = Int64.max + var uint64Min = UInt64.min + var uint64Max = UInt64.max + } + + let myValue = MyValue() + _testRoundTrip( + of: myValue, + expectedYAML: [ + "uint64Min: 0", + "uint64Max: 18446744073709551615", + "int64Min: -9223372036854775808", + "int64Max: 9223372036854775807", + ]) + } + + func test_CamelCaseEncoding() throws { + struct MyTestData: Codable, Equatable { + let thisIsAString: String + let thisIsABool: Bool + let thisIsAnInt: Int + let thisIsAnInt8: Int8 + let thisIsAnInt16: Int16 + let thisIsAnInt32: Int32 + let thisIsAnInt64: Int64 + let thisIsAUint: UInt + let thisIsAUint8: UInt8 + let thisIsAUint16: UInt16 + let thisIsAUint32: UInt32 + let thisIsAUint64: UInt64 + let thisIsAFloat: Float + let thisIsADouble: Double + let thisIsADate: Date + let thisIsAnArray: [Int] + let thisIsADictionary: [String: Bool] + } + + let data = MyTestData( + thisIsAString: "Hello", + thisIsABool: true, + thisIsAnInt: 1, + thisIsAnInt8: 2, + thisIsAnInt16: 3, + thisIsAnInt32: 4, + thisIsAnInt64: 5, + thisIsAUint: 6, + thisIsAUint8: 7, + thisIsAUint16: 8, + thisIsAUint32: 9, + thisIsAUint64: 10, + thisIsAFloat: 11, + thisIsADouble: 12, + thisIsADate: Date.init(timeIntervalSince1970: 0), + thisIsAnArray: [1, 2, 3], + thisIsADictionary: ["trueValue": true, "falseValue": false] + ) + + let encoder = YAMLEncoder() + encoder.keyEncodingStrategy = .camelCase + encoder.dateEncodingStrategy = .iso8601 + let encodedData = try encoder.encode(data) + guard let yaml = String(data: encodedData, encoding: .utf8) else { + XCTFail("Cant decode YAML object") + return + } + XCTAssertTrue(yaml.contains("ThisIsAString: Hello")) + XCTAssertTrue(yaml.contains("ThisIsABool: true")) + XCTAssertTrue(yaml.contains("ThisIsAnInt: 1")) + XCTAssertTrue(yaml.contains("ThisIsAnInt8: 2")) + XCTAssertTrue(yaml.contains("ThisIsAnInt16: 3")) + XCTAssertTrue(yaml.contains("ThisIsAnInt32: 4")) + XCTAssertTrue(yaml.contains("ThisIsAnInt64: 5")) + XCTAssertTrue(yaml.contains("ThisIsAUint: 6")) + XCTAssertTrue(yaml.contains("ThisIsAUint8: 7")) + XCTAssertTrue(yaml.contains("ThisIsAUint16: 8")) + XCTAssertTrue(yaml.contains("ThisIsAUint32: 9")) + XCTAssertTrue(yaml.contains("ThisIsAUint64: 10")) + XCTAssertTrue(yaml.contains("ThisIsAFloat: 11")) + XCTAssertTrue(yaml.contains("ThisIsADouble: 12")) + XCTAssertTrue(yaml.contains("ThisIsADate: 1970-01-01T00:00:00Z")) + XCTAssertTrue(yaml.contains("ThisIsAnArray:")) + XCTAssertTrue(yaml.contains("- 1")) + XCTAssertTrue(yaml.contains("- 2")) + XCTAssertTrue(yaml.contains("- 3")) + } + + func test_DictionaryCamelCaseEncoding() throws { + let camelCaseDictionary = ["camelCaseKey": ["nestedDictionary": 1]] + + let encoder = YAMLEncoder() + encoder.keyEncodingStrategy = .camelCase + let encodedData = try encoder.encode(camelCaseDictionary) + + guard let yaml = String(data: encodedData, encoding: .utf8) else { + XCTFail("Cant decode yaml object") + return + } + print(yaml) + XCTAssertTrue(yaml.contains("CamelCaseKey:")) + XCTAssertTrue(yaml.contains(" NestedDictionary: 1")) + } + + func test_OutputFormattingValues() { + XCTAssertEqual(YAMLEncoder.OutputFormatting.withoutEscapingSlashes.rawValue, 8) + } + + func test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip() throws { + struct Something: Codable { + struct Key: Codable, Hashable { + var x: String + } + + var dict: [Key: String] + + enum CodingKeys: String, CodingKey { + case dict + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.dict = try container.decode([Key: String].self, forKey: .dict) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(dict, forKey: .dict) + } + + init(dict: [Key: String]) { + self.dict = dict + } + } + + // let toEncode = Something(dict: [:]) + // let data = try YAMLEncoder().encode(toEncode) + // let result = try YAMLDecoder().decode(Something.self, from: data) + // XCTAssertEqual(result.dict.count, 0) + } + + // MARK: - Helper Functions + private var _yamlEmptyDictionary: [String] { + return [""] + } + + private func _testEncodeFailure(of value: T) { + do { + _ = try YAMLEncoder().encode(value) + XCTFail("Encode of top-level \(T.self) was expected to fail.") + } catch {} + } + + private func _testRoundTrip( + of value: T, + expectedYAML yaml: [String] = [], + dateEncodingStrategy: YAMLEncoder.DateEncodingStrategy = .deferredToDate, + dataEncodingStrategy: YAMLEncoder.DataEncodingStrategy = .base64, + nonConformingFloatEncodingStrategy: YAMLEncoder.NonConformingFloatEncodingStrategy = .throw + ) where T: Codable, T: Equatable { + var payload: Data! = nil + do { + let encoder = YAMLEncoder() + encoder.dateEncodingStrategy = dateEncodingStrategy + encoder.dataEncodingStrategy = dataEncodingStrategy + encoder.nonConformingFloatEncodingStrategy = nonConformingFloatEncodingStrategy + payload = try encoder.encode(value) + } catch { + XCTFail("Failed to encode \(T.self) to YAML: \(error)") + } + + // We do not compare expectedYAML to payload directly, because they might have values like + // {"name": "Bob", "age": 22} + // and + // {"age": 22, "name": "Bob"} + // which if compared as Data would not be equal, but the contained YAML values are equal. + // So we wrap them in a YAML type, which compares data as if it were a json. + + let payloadYAMLObject = String(data: payload, encoding: .utf8)! + let result = yaml.allSatisfy { payloadYAMLObject.contains($0) || $0 == "" } + XCTAssertTrue(result, "Produced YAML not identical to expected YAML.") + + if !result { + print("===========") + print(payloadYAMLObject) + print("-----------") + print(yaml.filter { !payloadYAMLObject.contains($0) }.compactMap { $0 }) + print("===========") + } + } + + func test_codingOf(value: T, toAndFrom stringValue: String) { + _testRoundTrip( + of: TopLevelObjectWrapper(value), + expectedYAML: ["value: \(stringValue)"]) + + _testRoundTrip( + of: TopLevelArrayWrapper(value), + expectedYAML: ["\(stringValue)"]) + } +} + +// MARK: - Helper Global Functions +func expectEqualPaths(_ lhs: [CodingKey?], _ rhs: [CodingKey?], _ prefix: String) { + if lhs.count != rhs.count { + XCTFail("\(prefix) [CodingKey?].count mismatch: \(lhs.count) != \(rhs.count)") + return + } + + for (k1, k2) in zip(lhs, rhs) { + switch (k1, k2) { + case (nil, nil): continue + case (let _k1?, nil): + XCTFail("\(prefix) CodingKey mismatch: \(type(of: _k1)) != nil") + return + case (nil, let _k2?): + XCTFail("\(prefix) CodingKey mismatch: nil != \(type(of: _k2))") + return + default: break + } + + let key1 = k1! + let key2 = k2! + + switch (key1.intValue, key2.intValue) { + case (nil, nil): break + case (let i1?, nil): + XCTFail("\(prefix) CodingKey.intValue mismatch: \(type(of: key1))(\(i1)) != nil") + return + case (nil, let i2?): + XCTFail("\(prefix) CodingKey.intValue mismatch: nil != \(type(of: key2))(\(i2))") + return + case (let i1?, let i2?): + guard i1 == i2 else { + XCTFail( + "\(prefix) CodingKey.intValue mismatch: \(type(of: key1))(\(i1)) != \(type(of: key2))(\(i2))" + ) + return + } + } + + XCTAssertEqual( + key1.stringValue, + key2.stringValue, + "\(prefix) CodingKey.stringValue mismatch: \(type(of: key1))('\(key1.stringValue)') != \(type(of: key2))('\(key2.stringValue)')" + ) + } +} + +// MARK: - Test Types +/* FIXME: Import from %S/Inputs/Coding/SharedTypes.swift somehow. */ + +// MARK: - Empty Types +private struct EmptyStruct: Codable, Equatable { + static func == (_ lhs: EmptyStruct, _ rhs: EmptyStruct) -> Bool { + return true + } +} + +private class EmptyClass: Codable, Equatable { + static func == (_ lhs: EmptyClass, _ rhs: EmptyClass) -> Bool { + return true + } +} + +// MARK: - Single-Value Types +/// A simple on-off switch type that encodes as a single Bool value. +private enum Switch: Codable { + case off + case on + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + switch try container.decode(Bool.self) { + case false: self = .off + case true: self = .on + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .off: try container.encode(false) + case .on: try container.encode(true) + } + } +} + +/// A simple timestamp type that encodes as a single Double value. +private struct Timestamp: Codable, Equatable { + let value: Double + + init(_ value: Double) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + value = try container.decode(Double.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.value) + } + + static func == (_ lhs: Timestamp, _ rhs: Timestamp) -> Bool { + return lhs.value == rhs.value + } +} + +/// A simple referential counter type that encodes as a single Int value. +private final class Counter: Codable, Equatable { + var count: Int = 0 + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + count = try container.decode(Int.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.count) + } + + static func == (_ lhs: Counter, _ rhs: Counter) -> Bool { + return lhs === rhs || lhs.count == rhs.count + } +} + +// MARK: - Structured Types +/// A simple address type that encodes as a dictionary of values. +private struct Address: Codable, Equatable { + let street: String + let city: String + let state: String + let zipCode: Int + let country: String + + init(street: String, city: String, state: String, zipCode: Int, country: String) { + self.street = street + self.city = city + self.state = state + self.zipCode = zipCode + self.country = country + } + + static func == (_ lhs: Address, _ rhs: Address) -> Bool { + return lhs.street == rhs.street && lhs.city == rhs.city && lhs.state == rhs.state + && lhs.zipCode == rhs.zipCode && lhs.country == rhs.country + } + + static var testValue: Address { + return Address( + street: "1 Infinite Loop", + city: "Cupertino", + state: "CA", + zipCode: 95014, + country: "United States") + } +} + +/// A simple person class that encodes as a dictionary of values. +private class Person: Codable, Equatable { + let name: String + let email: String + + // FIXME: This property is present only in order to test the expected result of Codable synthesis in the compiler. + // We want to test against expected encoded output (to ensure this generates an encodeIfPresent call), but we need an output format for that. + // Once we have a VerifyingEncoder for compiler unit tests, we should move this test there. + let website: URL? + + init(name: String, email: String, website: URL? = nil) { + self.name = name + self.email = email + self.website = website + } + + static func == (_ lhs: Person, _ rhs: Person) -> Bool { + return lhs.name == rhs.name && lhs.email == rhs.email && lhs.website == rhs.website + } + + static var testValue: Person { + return Person(name: "Johnny Appleseed", email: "appleseed@apple.com") + } +} + +/// A simple company struct which encodes as a dictionary of nested values. +private struct Company: Codable, Equatable { + let address: Address + var employees: [Person] + + init(address: Address, employees: [Person]) { + self.address = address + self.employees = employees + } + + static func == (_ lhs: Company, _ rhs: Company) -> Bool { + return lhs.address == rhs.address && lhs.employees == rhs.employees + } + + static var testValue: Company { + return Company(address: Address.testValue, employees: [Person.testValue]) + } +} + +// MARK: - Helper Types + +/// A key type which can take on any string or integer value. +/// This needs to mirror _YAMLKey. +private struct _TestKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + init(index: Int) { + self.stringValue = "Index \(index)" + self.intValue = index + } +} + +/// Wraps a type T so that it can be encoded at the top level of a payload. +private struct TopLevelArrayWrapper: Codable, Equatable where T: Codable, T: Equatable { + let value: T + + init(_ value: T) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(value) + } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + value = try container.decode(T.self) + assert(container.isAtEnd) + } + + static func == (_ lhs: TopLevelArrayWrapper, _ rhs: TopLevelArrayWrapper) -> Bool { + return lhs.value == rhs.value + } +} + +private struct FloatNaNPlaceholder: Codable, Equatable { + init() {} + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(Float.nan) + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let float = try container.decode(Float.self) + if !float.isNaN { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, debugDescription: "Couldn't decode NaN.")) + } + } + + static func == (_ lhs: FloatNaNPlaceholder, _ rhs: FloatNaNPlaceholder) -> Bool { + return true + } +} + +private struct DoubleNaNPlaceholder: Codable, Equatable { + init() {} + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(Double.nan) + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let double = try container.decode(Double.self) + if !double.isNaN { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, debugDescription: "Couldn't decode NaN.")) + } + } + + static func == (_ lhs: DoubleNaNPlaceholder, _ rhs: DoubleNaNPlaceholder) -> Bool { + return true + } +} + +/// A type which encodes as an array directly through a single value container. +struct Numbers: Codable, Equatable { + let values = [4, 8, 15, 16, 23, 42] + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let decodedValues = try container.decode([Int].self) + guard decodedValues == values else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, debugDescription: "The Numbers are wrong!")) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + static func == (_ lhs: Numbers, _ rhs: Numbers) -> Bool { + return lhs.values == rhs.values + } + + static var testValue: Numbers { + return Numbers() + } +} + +/// A type which encodes as a dictionary directly through a single value container. +private final class Mapping: Codable, Equatable { + let values: [String: URL] + + init(values: [String: URL]) { + self.values = values + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + values = try container.decode([String: URL].self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + static func == (_ lhs: Mapping, _ rhs: Mapping) -> Bool { + return lhs === rhs || lhs.values == rhs.values + } + + static var testValue: Mapping { + return Mapping(values: [ + "Apple": URL(string: "http://apple.com")!, + "localhost": URL(string: "http://127.0.0.1")!, + ]) + } +} + +struct NestedContainersTestType: Encodable { + let testSuperEncoder: Bool + + init(testSuperEncoder: Bool = false) { + self.testSuperEncoder = testSuperEncoder + } + + enum TopLevelCodingKeys: Int, CodingKey { + case a + case b + case c + } + + enum IntermediateCodingKeys: Int, CodingKey { + case one + case two + } + + func encode(to encoder: Encoder) throws { + if self.testSuperEncoder { + var topLevelContainer = encoder.container(keyedBy: TopLevelCodingKeys.self) + expectEqualPaths(encoder.codingPath, [], "Top-level Encoder's codingPath changed.") + expectEqualPaths( + topLevelContainer.codingPath, [], + "New first-level keyed container has non-empty codingPath.") + + let superEncoder = topLevelContainer.superEncoder(forKey: .a) + expectEqualPaths(encoder.codingPath, [], "Top-level Encoder's codingPath changed.") + expectEqualPaths( + topLevelContainer.codingPath, [], "First-level keyed container's codingPath changed.") + expectEqualPaths( + superEncoder.codingPath, [TopLevelCodingKeys.a], + "New superEncoder had unexpected codingPath.") + _testNestedContainers(in: superEncoder, baseCodingPath: [TopLevelCodingKeys.a]) + } else { + _testNestedContainers(in: encoder, baseCodingPath: []) + } + } + + func _testNestedContainers(in encoder: Encoder, baseCodingPath: [CodingKey?]) { + expectEqualPaths(encoder.codingPath, baseCodingPath, "New encoder has non-empty codingPath.") + + // codingPath should not change upon fetching a non-nested container. + var firstLevelContainer = encoder.container(keyedBy: TopLevelCodingKeys.self) + expectEqualPaths(encoder.codingPath, baseCodingPath, "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath, + "New first-level keyed container has non-empty codingPath.") + + // Nested Keyed Container + do { + // Nested container for key should have a new key pushed on. + var secondLevelContainer = firstLevelContainer.nestedContainer( + keyedBy: IntermediateCodingKeys.self, forKey: .a) + expectEqualPaths( + encoder.codingPath, baseCodingPath, "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath, + "First-level keyed container's codingPath changed.") + expectEqualPaths( + secondLevelContainer.codingPath, baseCodingPath + [TopLevelCodingKeys.a], + "New second-level keyed container had unexpected codingPath.") + + // Inserting a keyed container should not change existing coding paths. + let thirdLevelContainerKeyed = secondLevelContainer.nestedContainer( + keyedBy: IntermediateCodingKeys.self, forKey: .one) + expectEqualPaths( + encoder.codingPath, baseCodingPath, "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath, + "First-level keyed container's codingPath changed.") + expectEqualPaths( + secondLevelContainer.codingPath, baseCodingPath + [TopLevelCodingKeys.a], + "Second-level keyed container's codingPath changed.") + expectEqualPaths( + thirdLevelContainerKeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.a, IntermediateCodingKeys.one], + "New third-level keyed container had unexpected codingPath.") + + // Inserting an unkeyed container should not change existing coding paths. + let thirdLevelContainerUnkeyed = secondLevelContainer.nestedUnkeyedContainer(forKey: .two) + expectEqualPaths( + encoder.codingPath, baseCodingPath + [], "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath + [], + "First-level keyed container's codingPath changed.") + expectEqualPaths( + secondLevelContainer.codingPath, baseCodingPath + [TopLevelCodingKeys.a], + "Second-level keyed container's codingPath changed.") + expectEqualPaths( + thirdLevelContainerUnkeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.a, IntermediateCodingKeys.two], + "New third-level unkeyed container had unexpected codingPath.") + } + + // Nested Unkeyed Container + do { + // Nested container for key should have a new key pushed on. + var secondLevelContainer = firstLevelContainer.nestedUnkeyedContainer(forKey: .b) + expectEqualPaths( + encoder.codingPath, baseCodingPath, "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath, + "First-level keyed container's codingPath changed.") + expectEqualPaths( + secondLevelContainer.codingPath, baseCodingPath + [TopLevelCodingKeys.b], + "New second-level keyed container had unexpected codingPath.") + + // Appending a keyed container should not change existing coding paths. + let thirdLevelContainerKeyed = secondLevelContainer.nestedContainer( + keyedBy: IntermediateCodingKeys.self) + expectEqualPaths( + encoder.codingPath, baseCodingPath, "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath, + "First-level keyed container's codingPath changed.") + expectEqualPaths( + secondLevelContainer.codingPath, baseCodingPath + [TopLevelCodingKeys.b], + "Second-level unkeyed container's codingPath changed.") + expectEqualPaths( + thirdLevelContainerKeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.b, _TestKey(index: 0)], + "New third-level keyed container had unexpected codingPath.") + + // Appending an unkeyed container should not change existing coding paths. + let thirdLevelContainerUnkeyed = secondLevelContainer.nestedUnkeyedContainer() + expectEqualPaths( + encoder.codingPath, baseCodingPath, "Top-level Encoder's codingPath changed.") + expectEqualPaths( + firstLevelContainer.codingPath, baseCodingPath, + "First-level keyed container's codingPath changed.") + expectEqualPaths( + secondLevelContainer.codingPath, baseCodingPath + [TopLevelCodingKeys.b], + "Second-level unkeyed container's codingPath changed.") + expectEqualPaths( + thirdLevelContainerUnkeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.b, _TestKey(index: 1)], + "New third-level unkeyed container had unexpected codingPath.") + } + } +} + +// MARK: - Helpers + +private struct YAML: Equatable { + private var jsonObject: Any + + fileprivate init(data: Data) throws { + self.jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + } + + static func == (lhs: YAML, rhs: YAML) -> Bool { + switch (lhs.jsonObject, rhs.jsonObject) { + case let (lhs, rhs) as ([AnyHashable: Any], [AnyHashable: Any]): + return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs) + case let (lhs, rhs) as ([Any], [Any]): + return NSArray(array: lhs) == NSArray(array: rhs) + default: + return false + } + } +} + +// MARK: - Run Tests + +extension TestYAMLEncoder { + static var allTests: [(String, (TestYAMLEncoder) -> () throws -> Void)] { + return [ + ("test_encodingTopLevelFragments", test_encodingTopLevelFragments), + ("test_encodingTopLevelEmptyStruct", test_encodingTopLevelEmptyStruct), + ("test_encodingTopLevelEmptyClass", test_encodingTopLevelEmptyClass), + ("test_encodingTopLevelSingleValueEnum", test_encodingTopLevelSingleValueEnum), + ("test_encodingTopLevelSingleValueStruct", test_encodingTopLevelSingleValueStruct), + ("test_encodingTopLevelSingleValueClass", test_encodingTopLevelSingleValueClass), + ("test_encodingTopLevelStructuredStruct", test_encodingTopLevelStructuredStruct), + ("test_encodingTopLevelStructuredClass", test_encodingTopLevelStructuredClass), + ("test_encodingTopLevelStructuredSingleStruct", test_encodingTopLevelStructuredSingleStruct), + ("test_encodingTopLevelStructuredSingleClass", test_encodingTopLevelStructuredSingleClass), + ("test_encodingTopLevelDeepStructuredType", test_encodingTopLevelDeepStructuredType), + ("test_encodingOutputFormattingDefault", test_encodingOutputFormattingDefault), + ("test_encodingOutputFormattingPrettyPrinted", test_encodingOutputFormattingPrettyPrinted), + ("test_encodingOutputFormattingSortedKeys", test_encodingOutputFormattingSortedKeys), + ( + "test_encodingOutputFormattingPrettyPrintedSortedKeys", + test_encodingOutputFormattingPrettyPrintedSortedKeys + ), + ("test_encodingDate", test_encodingDate), + ("test_encodingDateSecondsSince1970", test_encodingDateSecondsSince1970), + ("test_encodingDateMillisecondsSince1970", test_encodingDateMillisecondsSince1970), + ("test_encodingDateISO8601", test_encodingDateISO8601), + ("test_encodingDateFormatted", test_encodingDateFormatted), + ("test_encodingDateCustom", test_encodingDateCustom), + ("test_encodingDateCustomEmpty", test_encodingDateCustomEmpty), + ("test_encodingBase64Data", test_encodingBase64Data), + ("test_encodingCustomData", test_encodingCustomData), + ("test_encodingCustomDataEmpty", test_encodingCustomDataEmpty), + ("test_encodingNonConformingFloats", test_encodingNonConformingFloats), + ("test_encodingNonConformingFloatStrings", test_encodingNonConformingFloatStrings), + // ("test_encodeDecodeNumericTypesBaseline", test_encodeDecodeNumericTypesBaseline), + ("test_nestedContainerCodingPaths", test_nestedContainerCodingPaths), + ("test_superEncoderCodingPaths", test_superEncoderCodingPaths), + ("test_notFoundSuperDecoder", test_notFoundSuperDecoder), + ("test_codingOfBool", test_codingOfBool), + ("test_codingOfNil", test_codingOfNil), + ("test_codingOfInt8", test_codingOfInt8), + ("test_codingOfUInt8", test_codingOfUInt8), + ("test_codingOfInt16", test_codingOfInt16), + ("test_codingOfUInt16", test_codingOfUInt16), + ("test_codingOfInt32", test_codingOfInt32), + ("test_codingOfUInt32", test_codingOfUInt32), + ("test_codingOfInt64", test_codingOfInt64), + ("test_codingOfUInt64", test_codingOfUInt64), + ("test_codingOfInt", test_codingOfInt), + ("test_codingOfUInt", test_codingOfUInt), + ("test_codingOfFloat", test_codingOfFloat), + ("test_codingOfDouble", test_codingOfDouble), + ("test_codingOfDecimal", test_codingOfDecimal), + ("test_codingOfString", test_codingOfString), + ("test_codingOfURL", test_codingOfURL), + ("test_codingOfUIntMinMax", test_codingOfUIntMinMax), + ("test_snake_case_encoding", test_CamelCaseEncoding), + ("test_dictionary_snake_case_encoding", test_DictionaryCamelCaseEncoding), + ("test_OutputFormattingValues", test_OutputFormattingValues), + ( + "test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip", + test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip + ), + ] + } +} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 32507dcf..74783262 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -41,7 +41,8 @@ services: LAMBDA_USE_LOCAL_DEPS=true swift build --package-path Examples/Foundation && LAMBDA_USE_LOCAL_DEPS=true swift build --package-path Examples/JSON && LAMBDA_USE_LOCAL_DEPS=true swift build --package-path Examples/LocalDebugging/MyLambda && - LAMBDA_USE_LOCAL_DEPS=true swift test --package-path Examples/Testing + LAMBDA_USE_LOCAL_DEPS=true swift test --package-path Examples/Testing && + LAMBDA_USE_LOCAL_DEPS=true swift test --package-path Examples/SAM " # util