From 3af979acc1269dcaffecf9b4e4a6aed1cc4a6e85 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 22 Dec 2022 11:41:58 +0100 Subject: [PATCH 01/79] initial commit for deployer plugin + SAM example --- Examples/SAM/Deploy/Deploy.swift | 31 ++ Examples/SAM/HttpApiLambda/Lambda.swift | 49 ++ Examples/SAM/Lambda-template.yml | 23 + Examples/SAM/Package.swift | 62 +++ Examples/SAM/SQSLambda/Lambda.swift | 40 ++ Package.swift | 22 + Plugins/AWSLambdaDeployer/Plugin.swift | 340 +++++++++++++ Plugins/AWSLambdaPackager/Plugin.swift | 2 +- .../DeploymentDefinition.swift | 447 ++++++++++++++++++ .../DeploymentDescriptor.swift | 164 +++++++ 10 files changed, 1179 insertions(+), 1 deletion(-) create mode 100755 Examples/SAM/Deploy/Deploy.swift create mode 100644 Examples/SAM/HttpApiLambda/Lambda.swift create mode 100644 Examples/SAM/Lambda-template.yml create mode 100644 Examples/SAM/Package.swift create mode 100644 Examples/SAM/SQSLambda/Lambda.swift create mode 100644 Plugins/AWSLambdaDeployer/Plugin.swift create mode 100644 Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift create mode 100644 Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift diff --git a/Examples/SAM/Deploy/Deploy.swift b/Examples/SAM/Deploy/Deploy.swift new file mode 100755 index 00000000..f44cc8b9 --- /dev/null +++ b/Examples/SAM/Deploy/Deploy.swift @@ -0,0 +1,31 @@ +import AWSLambdaDeploymentDescriptor +import Foundation + +@main +public struct HttpApiLambdaDeployment: DeploymentDescriptor { + static func main() throws { + HttpApiLambdaDeployment().run() + } + + public func eventSources(_ lambdaName: String) -> [EventSource] { + + if lambdaName == "HttpApiLambda" { + return [ + .httpApiEvent(.init()) + // .httpApiEvent(.init(method: .GET, path: "/test")), + ] + + } else if lambdaName == "SQSLambda" { + return [.sqsEvent(.init(queue: "swift-lambda-test"))] + + } else { + fatalError("Unknown Lambda name : \(lambdaName)") + } + } + + public func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { + // return the same env variables for all functions + return EnvironmentVariable([ "LOG_LEVEL": "debug" ]) + } + +} diff --git a/Examples/SAM/HttpApiLambda/Lambda.swift b/Examples/SAM/HttpApiLambda/Lambda.swift new file mode 100644 index 00000000..f826828d --- /dev/null +++ b/Examples/SAM/HttpApiLambda/Lambda.swift @@ -0,0 +1,49 @@ +// ===----------------------------------------------------------------------===// +// +// 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: 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)") + } + } +} diff --git a/Examples/SAM/Lambda-template.yml b/Examples/SAM/Lambda-template.yml new file mode 100644 index 00000000..ee037e81 --- /dev/null +++ b/Examples/SAM/Lambda-template.yml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion : '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: A sample SAM template for deploying Lambda functions. + +Resources: +# HttpApi Function + HttpAPiFunction: + Type: AWS::Serverless::Function + Properties: + Architectures: + - "arm64" # required when code is compiled on M1 mac + Handler: Provided + Runtime: provided.al2 # required for arm64 + CodeUri: dist/HttpApiLambda/HttpApiLambda.zip + + # Instructs new versions to be published to an alias named "live". + AutoPublishAlias: live + Events: + HttpApiEvent: + Type: HttpApi + Properties: + Method: GET + Path: /test diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift new file mode 100644 index 00000000..58b9cd84 --- /dev/null +++ b/Examples/SAM/Package.swift @@ -0,0 +1,62 @@ +// 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 PackageDescription + +let package = Package( + name: "swift-aws-lambda-runtime-example", + platforms: [ + .macOS(.v12) + ], + products: [ + .executable(name: "HttpApiLambda", targets: ["HttpApiLambda"]), + .executable(name: "SQSLambda", targets: ["SQSLambda"]), + // this generate the AWS SAM template for deployment. It is called by the deployment plugin (swift package deploy) + .executable(name: "Deploy", targets: ["Deploy"]) + ], + dependencies: [ + // this is the dependency on the swift-aws-lambda-runtime library + // in real-world projects this would say + // .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), + .package(name: "swift-aws-lambda-runtime", path: "../.."), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") + ], + targets: [ + .executableTarget( + name: "Deploy", + dependencies: [ + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") + ], + path: "./Deploy" + ), + .executableTarget( + name: "HttpApiLambda", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], + path: "./HttpApiLambda" + ), + .executableTarget( + name: "SQSLambda", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], + path: "./SQSLambda" + ) + ] +) diff --git a/Examples/SAM/SQSLambda/Lambda.swift b/Examples/SAM/SQSLambda/Lambda.swift new file mode 100644 index 00000000..bb1a2ab0 --- /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: SimpleLambdaHandler { + 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/Package.swift b/Package.swift index a4559656..12497b80 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"]), + // plugin to deploy the lambda, relies on AWS SAM command line + .library(name: "AWSLambdaDeploymentDescriptor", targets: ["AWSLambdaDeploymentDescriptor"]), // for testing only .library(name: "AWSLambdaTesting", targets: ["AWSLambdaTesting"]), ], @@ -25,6 +29,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.4.2")), .package(url: "https://github.com/swift-server/swift-backtrace.git", .upToNextMajor(from: "1.2.3")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.1") ], targets: [ .target(name: "AWSLambdaRuntime", dependencies: [ @@ -49,6 +54,23 @@ let package = Package( ) ) ), + .target( + name: "AWSLambdaDeploymentDescriptor", + dependencies: [ + .product(name: "Yams", package: "Yams") + ], + 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"), diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift new file mode 100644 index 00000000..ce74c7e9 --- /dev/null +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -0,0 +1,340 @@ +// ===----------------------------------------------------------------------===// +// +// 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 + +@main +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 deploy") + } + + let samExecutable = try context.tool(named: "sam") + let samExecutableUrl = URL(fileURLWithPath: samExecutable.path.string) + let deploymentDescriptorExecutableUrl = configuration.deployExecutable + if configuration.verboseLogging { + print("-------------------------------------------------------------------------") + print("executables") + print("-------------------------------------------------------------------------") + print("SAM Executable : \(samExecutableUrl)") + print("Deployment Descriptor Executable : \(deploymentDescriptorExecutableUrl)") + } + + let currentDirectory = FileManager.default.currentDirectoryPath + let samDeploymentDescriptorUrl = URL(fileURLWithPath: currentDirectory) + .appendingPathComponent("sam.yaml") + do { + print("-------------------------------------------------------------------------") + print("generating SAM deployment descriptor") + configuration.verboseLogging ? print("\(samDeploymentDescriptorUrl)") : nil + print("-------------------------------------------------------------------------") + let samDeploymentDescriptor = try self.execute( + executable: deploymentDescriptorExecutableUrl, + arguments: configuration.products.compactMap { $0.name }, + logLevel: configuration.verboseLogging ? .debug : .silent) + try samDeploymentDescriptor.write( + to: samDeploymentDescriptorUrl, atomically: true, encoding: .utf8) + + print("-------------------------------------------------------------------------") + print("validating SAM deployment descriptor") + print("-------------------------------------------------------------------------") + try self.execute( + executable: samExecutableUrl, + arguments: ["validate", "-t", samDeploymentDescriptorUrl.path], + logLevel: configuration.verboseLogging ? .debug : .silent) + + if !configuration.noDeploy { + print("-------------------------------------------------------------------------") + print("deploying AWS Lambda function") + print("-------------------------------------------------------------------------") + try self.execute( + executable: samExecutableUrl, + arguments: ["deploy", "-t", samDeploymentDescriptorUrl.path], + logLevel: configuration.verboseLogging ? .debug : .silent) + } + } catch Errors.processFailed(_, _) { + print("The generated SAM template is invalid or can not be deployed.") + if configuration.verboseLogging { + print("File at : \(samDeploymentDescriptorUrl)") + } else { + print("Run the command again with --verbose argument to receive more details.") + } + } catch { + print("Can not execute file at:") + print("\(deploymentDescriptorExecutableUrl.path)") + print("or at:") + print("\(samExecutableUrl.path)") + print("Is SAM installed ? (brew tap aws/tap && brew install aws-sam-cli)") + print("Did you add a 'Deploy' executable target into your project's Package.swift ?") + print("Did you build the release version ? (swift build -c release)") + } + } + + @discardableResult + private func execute( + executable: URL, + arguments: [String], + customWorkingDirectory: URL? = nil, + logLevel: ProcessLogLevel + ) throws -> String { + try self.execute( + executable: Path(executable.path), + arguments: arguments, + customWorkingDirectory: customWorkingDirectory == nil + ? nil : Path(customWorkingDirectory!.path), + logLevel: logLevel) + } + + // ************************************************************** + // Below this line, the code is copied from the archiver plugin + // ************************************************************** + @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 + } + // ************************************************************** + // end copied code + // ************************************************************** +} + +private struct Configuration: CustomStringConvertible { + public let products: [Product] + public let deployExecutable: URL + public let explicitProducts: Bool + public let buildConfiguration: PackageManager.BuildConfiguration + public let noDeploy: Bool + public let verboseLogging: Bool + + public init( + context: PluginContext, + arguments: [String] + ) throws { + + // extrcat command line arguments + var argumentExtractor = ArgumentExtractor(arguments) + let nodeployArgument = argumentExtractor.extractFlag(named: "nodeploy") > 0 + let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 + let productsArgument = argumentExtractor.extractOption(named: "products") + let configurationArgument = argumentExtractor.extractOption(named: "configuration") + + // define deployment option + self.noDeploy = nodeployArgument + + // define logging verbosity + self.verboseLogging = verboseArgument + + // define products + self.explicitProducts = !productsArgument.isEmpty + if self.explicitProducts { + 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") + } + } + self.products = products + + } else { + self.products = context.package.products.filter { + $0 is ExecutableProduct && $0.name != "Deploy" + } + } + + // define build configuration + if let buildConfigurationName = configurationArgument.first { + guard + let buildConfiguration = PackageManager.BuildConfiguration(rawValue: buildConfigurationName) + else { + throw Errors.invalidArgument( + "invalid build configuration named '\(buildConfigurationName)'") + } + self.buildConfiguration = buildConfiguration + } else { + self.buildConfiguration = .release + } + + // search for deployment configuration executable + let deployProducts = context.package.products.filter { $0.name == "Deploy" } + guard deployProducts.count == 1, + deployProducts[0].targets.count == 1 + else { + throw Errors.deploymentDescriptorProductNotFound("Deploy") + } + for t in deployProducts[0].targets { + print("\(t.name) - \(t.directory)") + } +#if arch(arm64) + let arch = "arm64-apple-macosx" +#else + let arch = "x86_64-apple-macosx" +#endif + self.deployExecutable = URL(fileURLWithPath: deployProducts[0].targets[0].directory.string) + .deletingLastPathComponent() + .appendingPathComponent(".build/\(arch)/\(self.buildConfiguration)/Deploy") + + if self.verboseLogging { + print("-------------------------------------------------------------------------") + print("configuration") + print("-------------------------------------------------------------------------") + print(self) + } + } + + var description: String { + """ + { + products: \(self.products.map(\.name)) + buildConfiguration: \(self.buildConfiguration) + deployExecutable: \(self.deployExecutable) + } + """ + } +} + +private enum Errors: Error, CustomStringConvertible { + case invalidArgument(String) + case unsupportedPlatform(String) + case unknownProduct(String) + case productExecutableNotFound(String) + case deploymentDescriptorProductNotFound(String) + case processFailed([String], Int32) + + var description: String { + switch self { + case .invalidArgument(let description): + return description + case .unsupportedPlatform(let description): + return description + case .unknownProduct(let description): + return description + case .productExecutableNotFound(let product): + return "product executable not found '\(product)'" + case .deploymentDescriptorProductNotFound(let product): + return "your project Package.swift has no executable named '\(product)'" + case .processFailed(let arguments, let code): + return "\(arguments.joined(separator: " ")) failed with code \(code)" + } + } +} + +// ************************************************************** +// Below this line, the code is copied from the archiver plugin +// ************************************************************** + +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 + } +} diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index 9b4f318c..2a3f119e 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -326,7 +326,7 @@ private struct Configuration: CustomStringConvertible { self.products = products } else { - self.products = context.package.products.filter { $0 is ExecutableProduct } + self.products = context.package.products.filter { $0 is ExecutableProduct && $0.name != "Deploy" } } if let buildConfigurationName = configurationArgument.first { diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift new file mode 100644 index 00000000..4da52929 --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -0,0 +1,447 @@ +// ===----------------------------------------------------------------------===// +// +// 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 Foundation + +public protocol DeploymentDefinition: Encodable {} + +// 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 decsriptor. +// currently limited to the properties I needed for the examples. +// A immediate TODO if this code is accepted is to add more properties and more classes +public struct SAMDeployment: DeploymentDefinition { + + let awsTemplateFormatVersion: String + let transform: String + let description: String + var resources: [String: Resource] + + public init( + templateVersion: String = "2010-09-09", + transform: String = "AWS::Serverless-2016-10-31", + description: String = "A SAM template to deploy a Swift Lambda function", + resources: [Resource] = [] + ) { + + self.awsTemplateFormatVersion = templateVersion + self.transform = transform + self.description = description + self.resources = [String: Resource]() + + for res in resources { + self.resources[res.resourceLogicalName()] = res + } + } + + // allows to add more resource. It returns a new SAMDeploymentDescription with the updated list of resources + public func addResource(_ resource: Resource) -> SAMDeployment { + + var existingResources: [Resource] = self.resources.values.compactMap{ $0 } + existingResources.append(resource) + + return SAMDeployment(templateVersion: self.awsTemplateFormatVersion, + transform: self.transform, + description: self.description, + resources: existingResources) + } + + + enum CodingKeys: String, CodingKey { + case awsTemplateFormatVersion = "AWSTemplateFormatVersion" + case transform = "Transform" + case description = "Description" + case resources = "Resources" + } +} + +public enum Resource: Encodable { + + case function(_ name: String, _ function: ServerlessFunctionResource) + case simpleTable(_ name: String, _ table: SimpleTableResource) + case queue(_ name: String, _ queue: SQSResource) + + // a resource provides it's own key for encoding + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .function(_, let function): + try? container.encode(function) + case .simpleTable(_, let table): + try? container.encode(table) + case .queue(_, let queue): + try? container.encode(queue) + } + } + public func resourceLogicalName() -> String { + switch self { + case .function(let name, _): return name + case .simpleTable(let name, _): return name + case .queue(let name, _): return name + } + } +} + +//MARK: Lambda Function resource definition +/*--------------------------------------------------------------------------------------- + Lambda Function + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html +-----------------------------------------------------------------------------------------*/ + +public struct ServerlessFunctionResource: Encodable { + + let type: String = "AWS::Serverless::Function" + let properties: ServerlessFunctionProperties + + public init(codeUri: String, + eventSources: [EventSource] = [], + environment: EnvironmentVariable = EnvironmentVariable.none()) { + + self.properties = ServerlessFunctionProperties(codeUri: codeUri, + eventSources: eventSources, + environment: environment) + } + + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } +} + +public struct ServerlessFunctionProperties: Encodable { + public enum Architectures: String, Encodable { + case x64 = "x86_64" + case arm64 = "arm64" + } + let architectures: [Architectures] + let handler: String + let runtime: String + let codeUri: String + let autoPublishAlias: String + var eventSources: [String: EventSource] + var environment: EnvironmentVariable + + public init(codeUri: String, + eventSources: [EventSource] = [], + environment: EnvironmentVariable = EnvironmentVariable.none()) { + + #if arch(arm64) //when we build on Arm, we deploy on Arm + self.architectures = [.arm64] + #else + self.architectures = [.x64] + #endif + self.handler = "Provided" + self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 + self.autoPublishAlias = "Live" + self.codeUri = codeUri + self.eventSources = [String: EventSource]() + self.environment = environment + + for es in eventSources { + self.eventSources[es.eventLogicalName()] = es + } + } + + // custom encoding to not provide Environment variables when there is none + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.architectures, forKey: .architectures) + try container.encode(self.handler, forKey: .handler) + try container.encode(self.runtime, forKey: .runtime) + try container.encode(self.codeUri, forKey: .codeUri) + try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) + try container.encode(self.eventSources, forKey: .eventSources) + if !environment.isEmpty() { + try container.encode(self.environment, forKey: .environment) + } + } + + enum CodingKeys: String, CodingKey { + case architectures = "Architectures" + case handler = "Handler" + case runtime = "Runtime" + case codeUri = "CodeUri" + case autoPublishAlias = "AutoPublishAlias" + case eventSources = "Events" + case environment = "Environment" + } +} + +/* + Environment: + Variables: + LOG_LEVEL: debug + */ +public struct EnvironmentVariable: Codable { + public let variables: [String:String] + public init(_ variables: [String:String]) { + self.variables = variables + } + public static func none() -> EnvironmentVariable { return EnvironmentVariable([:]) } + public func isEmpty() -> Bool { return variables.count == 0 } + enum CodingKeys: String, CodingKey { + case variables = "Variables" + } +} + +//MARK: Lambda Function event source +public enum EventSource: Encodable, Equatable { + + // I put name as last parameters to allow unnamed default values + case httpApiEvent(_ httpApi: HttpApiEvent, _ name: String = "HttpApiEvent") + case sqsEvent(_ sqs: SQSEvent, _ name: String = "SQSEvent") + + // each source provide it's own top-level key + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .httpApiEvent(let httpApi, _): + try? container.encode(httpApi) + case .sqsEvent(let sqs, _): + try? container.encode(sqs) + } + } + + public func eventLogicalName() -> String { + switch self { + case .httpApiEvent(_, let name): return name + case .sqsEvent(_, let name): return name + } + } +} + +//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 +-----------------------------------------------------------------------------------------*/ + +public struct HttpApiEvent: Encodable, Equatable { + let type: String = "HttpApi" + let properties: HttpApiProperties? + public init(method: HttpVerb? = nil, path: String? = nil) { + if method != nil || path != nil { + self.properties = .init(method: method, path: path) + } else { + self.properties = nil + } + } + + // Properties is option, HttpApi without properties forwards all requests to the lambda function + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: HttpApiEventKeys.self) + try container.encode(self.type, forKey: .type) + if let properties = self.properties { + try container.encode(properties, forKey: .properties) + } + } + enum HttpApiEventKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } +} +struct HttpApiProperties: Encodable, Equatable { + init(method: HttpVerb? = nil, path: String? = nil) { + self.method = method + self.path = path + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: HttpApiKeys.self) + if let method = self.method { + try container.encode(method, forKey: .method) + } + if let path = self.path { + try container.encode(path, forKey: .path) + } + } + let method: HttpVerb? + let path: String? + + enum HttpApiKeys: String, CodingKey { + case method = "Method" + case path = "Path" + } +} + +public enum HttpVerb: String, Encodable { + case GET + case POST +} + +//MARK: SQS event definition +/*--------------------------------------------------------------------------------------- + SQS Event + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html +-----------------------------------------------------------------------------------------*/ +public struct SQSEvent: Encodable, Equatable { + var type: String = "SQS" + public let properties: SQSProperties + public init(queue: String) { + self.properties = .init(queue: queue) + } + public init(queueArn: String) { + //FIXME: check for Arn Format + self.properties = .init(queue: queueArn) + } + public init(queueRef: String) { + self.properties = .init(queue: "!GetAtt \(queueRef).Arn") + } + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } +} + +/** + 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 SQSProperties: Codable, Equatable { + + private var _queue: String = "" + + // Change encoding when queueName starts with !GetAtt - it should be + // Fn::GetAtt: [ logicalNameOfResource, attributeName ] + // doc : https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html + public var queue: String { + get { + return _queue + } + set(newQueue) { + if newQueue.starts(with: "!") { + let elements = newQueue.split(separator: " ") + guard elements.count == 2 else { + fatalError("Invalid intrisic function: \(newQueue), only one space allowed") + } + let key = String(elements[0]).replacingOccurrences(of: "!", with: "Fn::") + self.intrisicFunction[key] = elements[1].split(separator: ".").map{ String($0) } + } else { + self._queue = newQueue + } + } + } + var intrisicFunction: [String:[String]] = [:] + + + public init(queue: String) { + self.queue = queue + } + enum CodingKeys: String, CodingKey { + case _queue = "Queue" + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if intrisicFunction.isEmpty { + try container.encode(self.queue, forKey: ._queue) + } else { + try container.encode(intrisicFunction, forKey: ._queue) + } + } + +} + +//MARK: SQS queue resource definition +/*--------------------------------------------------------------------------------------- + SQS Queue Resource + + Documentation + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html +-----------------------------------------------------------------------------------------*/ +public struct SQSResource: Encodable { + + let type: String = "AWS::SQS::Queue" + public let properties: SQSResourceProperties + + public init(properties: SQSResourceProperties) { + self.properties = properties + } + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } +} + +public struct SQSResourceProperties: Encodable { + public let queueName: String + enum CodingKeys: String, CodingKey { + case queueName = "QueueName" + } +} + +//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 SimpleTableResource: Encodable { + + let type: String = "AWS::Serverless::SimpleTable" + let properties: SimpleTableProperties + + public init(properties: SimpleTableProperties) { + self.properties = properties + } + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } +} + +public struct SimpleTableProperties: Encodable { + let primaryKey: PrimaryKey + let tableName: String + let provisionedThroughput: ProvisionedThroughput? + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let provisionedThroughput = self.provisionedThroughput { + try container.encode(provisionedThroughput, forKey: .provisionedThroughput) + } + } + enum CodingKeys: String, CodingKey { + case primaryKey = "PrimaryKey" + case tableName = "TableName" + case provisionedThroughput = "ProvisionedThroughput" + } + struct PrimaryKey: Codable { + let name: String + let type: String + enum CodingKeys: String, CodingKey { + case name = "Name" + case type = "Type" + } + } + struct ProvisionedThroughput: Codable { + let readCapacityUnits: Int + let writeCapacityUnits: Int + enum CodingKeys: String, CodingKey { + case readCapacityUnits = "ReadCapacityUnits" + case writeCapacityUnits = "WriteCapacityUnits" + } + } +} + +public enum DeploymentEncodingError: Error { + case yamlError(causedBy: Error) + case jsonError(causedBy: Error) + case stringError(causedBy: Data) +} diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift new file mode 100644 index 00000000..4de986db --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -0,0 +1,164 @@ +// ===----------------------------------------------------------------------===// +// +// 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 Foundation +import Yams + +// this is the developer-visible part of the deployment decsriptor. +// the rest is generated automatically. +public protocol DeploymentDescriptor { + + // returns the event sources for a given Lambda function + func eventSources(_ lambdaName: String) -> [EventSource] + + // returns environment variables to associate with the given Lambda function + func environmentVariables(_ lambdaName: String) -> EnvironmentVariable + + // TODO: add the possibility to add additional resources. + // func addResource() -> Resource ? +} + +// Generates a deployment descriptor modelized by DeploymentDefinition +extension DeploymentDescriptor { + + // create the SAM deployment descriptor data structure + // this method use multiple data sources : + // - it calls protocol functions implemented by the lambda function developer to get function-specific + // details (event source, environment variables) + // - it uses command line arguments (list of lambda names) + // - it uses some default values (like the ZIP file produced by archive command) + public func deploymentDefinition() -> DeploymentDefinition { + + var additionalressources : [Resource] = [] + + // create function resources for each Lambda function + var resources = lambdaNames().map { name in + + // default output dir for archive plugin is + // .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/HttpApiLambda/HttpApiLambda.zip + // FIXME: what if use used --output-path option on packager ? + let package = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" + + // add event sources provided by Lambda developer + var eventSources = eventSources(name) + + // do we need to create a SQS queue ? Filter on SQSEvent source without Queue Arn + let sqsEventSources: [EventSource] = eventSources.filter{ + switch $0 { + case .httpApiEvent: return false + case .sqsEvent: return true //FIXME: check if an queue Arn is provided + // according to https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + // ARN Regex is arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*) + } + } + + // for each of SQSEvent Source without queue Arn, add a SQS resource and modify the event source to point ot that new resource + for sqsES in sqsEventSources { + + if case .sqsEvent(let event, _) = sqsES { + // add a queue resource to the SAM teamplate + let logicalName = logicalName(resourceType: "Queue", resourceName: event.properties.queue) + additionalressources.append(.queue(logicalName, + SQSResource(properties: SQSResourceProperties(queueName: event.properties.queue)))) + + // replace the event source to point to this new queue + eventSources.removeAll{ $0 == sqsES } + eventSources.append(EventSource.sqsEvent(.init(queueRef: logicalName))) + + } else { + fatalError("Non SQSEvent in our list of event sources") + } + } + + // finally, let's build the function definition + return Resource.function(name, + .init(codeUri: package, + eventSources: eventSources, + environment: environmentVariables(name))) + + } + + // add newly created resources (non-functions) + resources.append(contentsOf: additionalressources) + + // craete the SAM deployment decsriptor + return SAMDeployment(resources: resources) + } + + // returns environment variables to associate with the given Lambda function + // This is a default implementation to avoid forcing the lambda developer to implement it when not needed + func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { + return EnvironmentVariable.none() + } + + // entry point and main function. Lambda developer must call this function. + public func run() { + do { + let sam = try toYaml() + print(sam) + } catch { + print(error) + } + } + + // The lambda function names that are passed as command line argument + // it is used to infer the directory and the name of the ZIP files + public func lambdaNames() -> [String] { + if CommandLine.arguments.count < 2 { + fatalError( + "You must pass the AWS Lambda function names as list of arguments\n\nFor example: ./deploy LambdaOne LambdaTwo" + ) + } else { + return [String](CommandLine.arguments[1...]) + } + } + + // generate the YAML version of the deployment descriptor + public func toYaml() throws -> String { + let deploy = deploymentDefinition() + + do { + let yaml = try YAMLEncoder().encode(deploy) + return yaml + } catch { + throw DeploymentEncodingError.yamlError(causedBy: error) + } + } + + // generate the JSON version of the deployment descriptor + public func toJson() throws -> String { + let deploy = deploymentDefinition() + + do { + let jsonData = try JSONEncoder().encode(deploy) + guard let json = String(data: jsonData, encoding: .utf8) else { + throw DeploymentEncodingError.stringError(causedBy: jsonData) + } + return json + } catch { + throw DeploymentEncodingError.jsonError(causedBy: error) + } + } + + // Transform resourceName : + // remove space + // remove hyphen + // camel case + 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 + } +} From 7c4babb6800f8cda3aa540c9e1e7a2e6fb497ec8 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Fri, 23 Dec 2022 15:36:03 +0100 Subject: [PATCH 02/79] Remove unused example files --- Examples/SAM/Lambda-template.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 Examples/SAM/Lambda-template.yml diff --git a/Examples/SAM/Lambda-template.yml b/Examples/SAM/Lambda-template.yml deleted file mode 100644 index ee037e81..00000000 --- a/Examples/SAM/Lambda-template.yml +++ /dev/null @@ -1,23 +0,0 @@ -AWSTemplateFormatVersion : '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: A sample SAM template for deploying Lambda functions. - -Resources: -# HttpApi Function - HttpAPiFunction: - Type: AWS::Serverless::Function - Properties: - Architectures: - - "arm64" # required when code is compiled on M1 mac - Handler: Provided - Runtime: provided.al2 # required for arm64 - CodeUri: dist/HttpApiLambda/HttpApiLambda.zip - - # Instructs new versions to be published to an alias named "live". - AutoPublishAlias: live - Events: - HttpApiEvent: - Type: HttpApi - Properties: - Method: GET - Path: /test From 4f5cb9c88d04547d6098e8b5423a103c542a958b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 29 Dec 2022 08:07:10 +0100 Subject: [PATCH 03/79] Simplify API for function developers --- Examples/SAM/Deploy/Deploy.swift | 2 +- .../DeploymentDefinition.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Examples/SAM/Deploy/Deploy.swift b/Examples/SAM/Deploy/Deploy.swift index f44cc8b9..b4df5386 100755 --- a/Examples/SAM/Deploy/Deploy.swift +++ b/Examples/SAM/Deploy/Deploy.swift @@ -11,7 +11,7 @@ public struct HttpApiLambdaDeployment: DeploymentDescriptor { if lambdaName == "HttpApiLambda" { return [ - .httpApiEvent(.init()) + .httpApiEvent() // .httpApiEvent(.init(method: .GET, path: "/test")), ] diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 4da52929..4ebd4395 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -21,7 +21,7 @@ public protocol DeploymentDefinition: Encodable {} // a Swift definition of a SAM deployment decsriptor. // currently limited to the properties I needed for the examples. -// A immediate TODO if this code is accepted is to add more properties and more classes +// An immediate TODO if this code is accepted is to add more properties and more classes public struct SAMDeployment: DeploymentDefinition { let awsTemplateFormatVersion: String @@ -68,7 +68,7 @@ public struct SAMDeployment: DeploymentDefinition { } public enum Resource: Encodable { - + case function(_ name: String, _ function: ServerlessFunctionResource) case simpleTable(_ name: String, _ table: SimpleTableResource) case queue(_ name: String, _ queue: SQSResource) @@ -202,7 +202,7 @@ public struct EnvironmentVariable: Codable { public enum EventSource: Encodable, Equatable { // I put name as last parameters to allow unnamed default values - case httpApiEvent(_ httpApi: HttpApiEvent, _ name: String = "HttpApiEvent") + case httpApiEvent(_ httpApi: HttpApiEvent = HttpApiEvent(), _ name: String = "HttpApiEvent") case sqsEvent(_ sqs: SQSEvent, _ name: String = "SQSEvent") // each source provide it's own top-level key From 45733a65bb855720cd9236eeaa165d2c4d96effb Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 29 Dec 2022 08:37:33 +0100 Subject: [PATCH 04/79] fix comment --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 12497b80..2767c324 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( .plugin(name: "AWSLambdaPackager", targets: ["AWSLambdaPackager"]), // plugin to deploy the lambda, relies on AWS SAM command line .plugin(name: "AWSLambdaDeployer", targets: ["AWSLambdaDeployer"]), - // plugin to deploy the lambda, relies on AWS SAM command line + // Shared Library to generate a SAM deployment descriptor .library(name: "AWSLambdaDeploymentDescriptor", targets: ["AWSLambdaDeploymentDescriptor"]), // for testing only .library(name: "AWSLambdaTesting", targets: ["AWSLambdaTesting"]), From 8fd748793b12f08a04601e5e2e57884620d3f8cf Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 29 Dec 2022 22:32:32 +0100 Subject: [PATCH 05/79] simplify data structures by removing unecessary enums --- Examples/SAM/Deploy/Deploy.swift | 6 +- .../DeploymentDefinition.swift | 222 ++++++++---------- .../DeploymentDescriptor.swift | 44 ++-- 3 files changed, 124 insertions(+), 148 deletions(-) diff --git a/Examples/SAM/Deploy/Deploy.swift b/Examples/SAM/Deploy/Deploy.swift index b4df5386..57beedab 100755 --- a/Examples/SAM/Deploy/Deploy.swift +++ b/Examples/SAM/Deploy/Deploy.swift @@ -11,12 +11,12 @@ public struct HttpApiLambdaDeployment: DeploymentDescriptor { if lambdaName == "HttpApiLambda" { return [ - .httpApiEvent() - // .httpApiEvent(.init(method: .GET, path: "/test")), + .httpApi() + // .httpApi(method: .GET, path: "/test"), ] } else if lambdaName == "SQSLambda" { - return [.sqsEvent(.init(queue: "swift-lambda-test"))] + return [.sqs(queue: "swift-lambda-test")] } else { fatalError("Unknown Lambda name : \(lambdaName)") diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 4ebd4395..21ad006d 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -42,7 +42,7 @@ public struct SAMDeployment: DeploymentDefinition { self.resources = [String: Resource]() for res in resources { - self.resources[res.resourceLogicalName()] = res + self.resources[res.name()] = res } } @@ -67,32 +67,29 @@ public struct SAMDeployment: DeploymentDefinition { } } -public enum Resource: Encodable { - - case function(_ name: String, _ function: ServerlessFunctionResource) - case simpleTable(_ name: String, _ table: SimpleTableResource) - case queue(_ name: String, _ queue: SQSResource) +public protocol SAMResource: Encodable {} +public protocol SAMResourceProperties: Encodable {} + +public struct Resource: SAMResource { - // a resource provides it's own key for encoding - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - switch self { - case .function(_, let function): - try? container.encode(function) - case .simpleTable(_, let table): - try? container.encode(table) - case .queue(_, let queue): - try? container.encode(queue) - } + let type: String + let properties: SAMResourceProperties + let _name: String + + public func name() -> String { return _name } + + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" } - public func resourceLogicalName() -> String { - switch self { - case .function(let name, _): return name - case .simpleTable(let name, _): return name - case .queue(let name, _): return name - } + + // this is to make the compiler happy : Resource now confoms to Encodable + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encode(self.type, forKey: .type) + try? container.encode(self.properties, forKey: .properties) } + } //MARK: Lambda Function resource definition @@ -102,27 +99,23 @@ public enum Resource: Encodable { https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html -----------------------------------------------------------------------------------------*/ -public struct ServerlessFunctionResource: Encodable { - - let type: String = "AWS::Serverless::Function" - let properties: ServerlessFunctionProperties - - public init(codeUri: String, - eventSources: [EventSource] = [], - environment: EnvironmentVariable = EnvironmentVariable.none()) { +extension Resource { + public static func serverlessFunction(name: String, + codeUri: String, + eventSources: [EventSource] = [], + environment: EnvironmentVariable = EnvironmentVariable.none()) -> Resource { - self.properties = ServerlessFunctionProperties(codeUri: codeUri, + let properties = ServerlessFunctionProperties(codeUri: codeUri, eventSources: eventSources, environment: environment) + return Resource(type: "AWS::Serverless::Function", + properties: properties, + _name: name) } - - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" - } + } -public struct ServerlessFunctionProperties: Encodable { +public struct ServerlessFunctionProperties: SAMResourceProperties { public enum Architectures: String, Encodable { case x64 = "x86_64" case arm64 = "arm64" @@ -152,7 +145,7 @@ public struct ServerlessFunctionProperties: Encodable { self.environment = environment for es in eventSources { - self.eventSources[es.eventLogicalName()] = es + self.eventSources[es.name()] = es } } @@ -199,32 +192,38 @@ public struct EnvironmentVariable: Codable { } //MARK: Lambda Function event source -public enum EventSource: Encodable, Equatable { + +public protocol SAMEvent : Encodable, Equatable {} +public protocol SAMEventProperties : Encodable {} + +public struct EventSource: SAMEvent { + + let type: String + let properties: SAMEventProperties? + let _name: String - // I put name as last parameters to allow unnamed default values - case httpApiEvent(_ httpApi: HttpApiEvent = HttpApiEvent(), _ name: String = "HttpApiEvent") - case sqsEvent(_ sqs: SQSEvent, _ name: String = "SQSEvent") + public func name() -> String { return _name } - // each source provide it's own top-level key - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - switch self { - case .httpApiEvent(let httpApi, _): - try? container.encode(httpApi) - case .sqsEvent(let sqs, _): - try? container.encode(sqs) - } + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" } - public func eventLogicalName() -> String { - switch self { - case .httpApiEvent(_, let name): return name - case .sqsEvent(_, let name): return name + public static func == (lhs: EventSource, rhs: EventSource) -> Bool { + lhs.type == rhs.type && lhs.name() == rhs.name() + } + + // this is to make the compiler happy : Resource now confoms 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: HTTP API Event definition /*--------------------------------------------------------------------------------------- HTTP API Event (API Gateway v2) @@ -232,31 +231,23 @@ public enum EventSource: Encodable, Equatable { https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-httpapi.html -----------------------------------------------------------------------------------------*/ -public struct HttpApiEvent: Encodable, Equatable { - let type: String = "HttpApi" - let properties: HttpApiProperties? - public init(method: HttpVerb? = nil, path: String? = nil) { +extension EventSource { + public static func httpApi(name: String = "HttpApiEvent", + method: HttpVerb? = nil, + path: String? = nil) -> EventSource { + + var properties: SAMEventProperties? = nil if method != nil || path != nil { - self.properties = .init(method: method, path: path) - } else { - self.properties = nil - } - } - - // Properties is option, HttpApi without properties forwards all requests to the lambda function - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: HttpApiEventKeys.self) - try container.encode(self.type, forKey: .type) - if let properties = self.properties { - try container.encode(properties, forKey: .properties) + properties = HttpApiProperties(method: method, path: path) } - } - enum HttpApiEventKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" + + return EventSource(type: "HttpApi", + properties: properties, + _name: name) } } -struct HttpApiProperties: Encodable, Equatable { + +struct HttpApiProperties: SAMEventProperties, Equatable { init(method: HttpVerb? = nil, path: String? = nil) { self.method = method self.path = path @@ -290,22 +281,16 @@ public enum HttpVerb: String, Encodable { https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html -----------------------------------------------------------------------------------------*/ -public struct SQSEvent: Encodable, Equatable { - var type: String = "SQS" - public let properties: SQSProperties - public init(queue: String) { - self.properties = .init(queue: queue) - } - public init(queueArn: String) { - //FIXME: check for Arn Format - self.properties = .init(queue: queueArn) - } - public init(queueRef: String) { - self.properties = .init(queue: "!GetAtt \(queueRef).Arn") - } - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" + +extension EventSource { + public static func sqs(name: String = "SQSEvent", + queue: String) -> EventSource { + + let properties = SQSEventProperties(queue) + + return EventSource(type: "SQS", + properties: properties, + _name: name) } } @@ -313,7 +298,7 @@ public struct SQSEvent: Encodable, Equatable { 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 SQSProperties: Codable, Equatable { +public struct SQSEventProperties: SAMEventProperties, Equatable { private var _queue: String = "" @@ -339,8 +324,7 @@ public struct SQSProperties: Codable, Equatable { } var intrisicFunction: [String:[String]] = [:] - - public init(queue: String) { + public init(_ queue: String) { self.queue = queue } enum CodingKeys: String, CodingKey { @@ -364,21 +348,18 @@ public struct SQSProperties: Codable, Equatable { Documentation https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html -----------------------------------------------------------------------------------------*/ -public struct SQSResource: Encodable { - - let type: String = "AWS::SQS::Queue" - public let properties: SQSResourceProperties - - public init(properties: SQSResourceProperties) { - self.properties = properties - } - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" +extension Resource { + public static func sqsQueue(name: String, + properties: SQSResourceProperties) -> Resource { + + return Resource(type: "AWS::SQS::Queue", + properties: properties, + _name: name) } + } -public struct SQSResourceProperties: Encodable { +public struct SQSResourceProperties: SAMResourceProperties { public let queueName: String enum CodingKeys: String, CodingKey { case queueName = "QueueName" @@ -393,21 +374,18 @@ public struct SQSResourceProperties: Encodable { https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-simpletable.html -----------------------------------------------------------------------------------------*/ -public struct SimpleTableResource: Encodable { - - let type: String = "AWS::Serverless::SimpleTable" - let properties: SimpleTableProperties - - public init(properties: SimpleTableProperties) { - self.properties = properties - } - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" +extension Resource { + public static func simpleTable(name: String, + properties: SimpleTableProperties) -> Resource { + + return Resource(type: "AWS::Serverless::SimpleTable", + properties: properties, + _name: name) } + } -public struct SimpleTableProperties: Encodable { +public struct SimpleTableProperties: SAMResourceProperties { let primaryKey: PrimaryKey let tableName: String let provisionedThroughput: ProvisionedThroughput? diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 4de986db..b7022862 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -55,37 +55,35 @@ extension DeploymentDescriptor { // do we need to create a SQS queue ? Filter on SQSEvent source without Queue Arn let sqsEventSources: [EventSource] = eventSources.filter{ - switch $0 { - case .httpApiEvent: return false - case .sqsEvent: return true //FIXME: check if an queue Arn is provided - // according to https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - // ARN Regex is arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*) - } + //FIXME: check if an queue Arn is provided + // according to https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + // ARN Regex is arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*) + $0.type == "SQS" // && TODO .... } - + // for each of SQSEvent Source without queue Arn, add a SQS resource and modify the event source to point ot that new resource for sqsES in sqsEventSources { - if case .sqsEvent(let event, _) = sqsES { - // add a queue resource to the SAM teamplate - let logicalName = logicalName(resourceType: "Queue", resourceName: event.properties.queue) - additionalressources.append(.queue(logicalName, - SQSResource(properties: SQSResourceProperties(queueName: event.properties.queue)))) - - // replace the event source to point to this new queue - eventSources.removeAll{ $0 == sqsES } - eventSources.append(EventSource.sqsEvent(.init(queueRef: logicalName))) - - } else { - fatalError("Non SQSEvent in our list of event sources") + // add a queue resource to the SAM teamplate + guard let queueName = (sqsES.properties as? SQSEventProperties)?.queue else { + fatalError("SQS Event Source's properties is not a SAMEventProperties") } + let logicalName = logicalName(resourceType: "Queue", + resourceName: queueName) + additionalressources.append(Resource.sqsQueue(name: logicalName, + properties: SQSResourceProperties(queueName: queueName))) + + // replace the event source to point to this new queue resource + eventSources.removeAll{ $0 == sqsES } + eventSources.append(EventSource.sqs(queue: "!GetAtt \(logicalName).Arn")) + } // finally, let's build the function definition - return Resource.function(name, - .init(codeUri: package, - eventSources: eventSources, - environment: environmentVariables(name))) + return Resource.serverlessFunction(name: name, + codeUri: package, + eventSources: eventSources, + environment: environmentVariables(name)) } From 8ad22e6eb2e7b0d1cc759d8bbc64254b1c72cc65 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Fri, 30 Dec 2022 15:12:49 +0100 Subject: [PATCH 06/79] simplify API --- .../DeploymentDefinition.swift | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 21ad006d..01b95d18 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -42,7 +42,7 @@ public struct SAMDeployment: DeploymentDefinition { self.resources = [String: Resource]() for res in resources { - self.resources[res.name()] = res + self.resources[res.name] = res } } @@ -74,10 +74,8 @@ public struct Resource: SAMResource { let type: String let properties: SAMResourceProperties - let _name: String - - public func name() -> String { return _name } - + let name: String + enum CodingKeys: String, CodingKey { case type = "Type" case properties = "Properties" @@ -110,7 +108,7 @@ extension Resource { environment: environment) return Resource(type: "AWS::Serverless::Function", properties: properties, - _name: name) + name: name) } } @@ -145,7 +143,7 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { self.environment = environment for es in eventSources { - self.eventSources[es.name()] = es + self.eventSources[es.name] = es } } @@ -200,17 +198,15 @@ public struct EventSource: SAMEvent { let type: String let properties: SAMEventProperties? - let _name: String - - public func name() -> String { return _name } - + let name: String + enum CodingKeys: String, CodingKey { case type = "Type" case properties = "Properties" } public static func == (lhs: EventSource, rhs: EventSource) -> Bool { - lhs.type == rhs.type && lhs.name() == rhs.name() + lhs.type == rhs.type && lhs.name == rhs.name } // this is to make the compiler happy : Resource now confoms to Encodable @@ -243,7 +239,7 @@ extension EventSource { return EventSource(type: "HttpApi", properties: properties, - _name: name) + name: name) } } @@ -290,7 +286,7 @@ extension EventSource { return EventSource(type: "SQS", properties: properties, - _name: name) + name: name) } } @@ -354,7 +350,7 @@ extension Resource { return Resource(type: "AWS::SQS::Queue", properties: properties, - _name: name) + name: name) } } @@ -380,7 +376,7 @@ extension Resource { return Resource(type: "AWS::Serverless::SimpleTable", properties: properties, - _name: name) + name: name) } } From 2f623be3efdc267d0127a7a54cadfc12f160b10b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 31 Dec 2022 15:35:47 +0100 Subject: [PATCH 07/79] add addResource API --- .../DeploymentDefinition.swift | 18 +++--------------- .../DeploymentDescriptor.swift | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 01b95d18..7ba1fe70 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -46,19 +46,6 @@ public struct SAMDeployment: DeploymentDefinition { } } - // allows to add more resource. It returns a new SAMDeploymentDescription with the updated list of resources - public func addResource(_ resource: Resource) -> SAMDeployment { - - var existingResources: [Resource] = self.resources.values.compactMap{ $0 } - existingResources.append(resource) - - return SAMDeployment(templateVersion: self.awsTemplateFormatVersion, - transform: self.transform, - description: self.description, - resources: existingResources) - } - - enum CodingKeys: String, CodingKey { case awsTemplateFormatVersion = "AWSTemplateFormatVersion" case transform = "Transform" @@ -87,7 +74,6 @@ public struct Resource: SAMResource { try? container.encode(self.type, forKey: .type) try? container.encode(self.properties, forKey: .properties) } - } //MARK: Lambda Function resource definition @@ -384,9 +370,11 @@ extension Resource { public struct SimpleTableProperties: SAMResourceProperties { let primaryKey: PrimaryKey let tableName: String - let provisionedThroughput: ProvisionedThroughput? + let provisionedThroughput: ProvisionedThroughput? = nil public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encode(tableName, forKey: .tableName) + try? container.encode(primaryKey, forKey: .primaryKey) if let provisionedThroughput = self.provisionedThroughput { try container.encode(provisionedThroughput, forKey: .provisionedThroughput) } diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index b7022862..82fedc59 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -25,8 +25,8 @@ public protocol DeploymentDescriptor { // returns environment variables to associate with the given Lambda function func environmentVariables(_ lambdaName: String) -> EnvironmentVariable - // TODO: add the possibility to add additional resources. - // func addResource() -> Resource ? + // returns additional resources to create in the cloud (ex : a dynamoDB table) + func addResource() -> [Resource] } // Generates a deployment descriptor modelized by DeploymentDefinition @@ -43,7 +43,7 @@ extension DeploymentDescriptor { var additionalressources : [Resource] = [] // create function resources for each Lambda function - var resources = lambdaNames().map { name in + var resources = self.lambdaNames().map { name in // default output dir for archive plugin is // .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/HttpApiLambda/HttpApiLambda.zip @@ -51,7 +51,7 @@ extension DeploymentDescriptor { let package = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" // add event sources provided by Lambda developer - var eventSources = eventSources(name) + var eventSources = self.eventSources(name) // do we need to create a SQS queue ? Filter on SQSEvent source without Queue Arn let sqsEventSources: [EventSource] = eventSources.filter{ @@ -90,16 +90,25 @@ extension DeploymentDescriptor { // add newly created resources (non-functions) resources.append(contentsOf: additionalressources) + // add developer-provided resources + resources.append(contentsOf: self.addResource()) + // craete the SAM deployment decsriptor return SAMDeployment(resources: resources) } // returns environment variables to associate with the given Lambda function // This is a default implementation to avoid forcing the lambda developer to implement it when not needed - func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { + public func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { return EnvironmentVariable.none() } + // returns additional resources to create in the cloud (ex : a dynamoDB table) + // This is a default implementation to avoid forcing the lambda developer to implement it when not needed + public func addResource() -> [Resource] { + return [] + } + // entry point and main function. Lambda developer must call this function. public func run() { do { From c3d73a9297b3b141037da8ceb0dcad2171427add Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 31 Dec 2022 17:01:10 +0100 Subject: [PATCH 08/79] add support for passing SQS queue Arn --- .../DeploymentDescriptor.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 82fedc59..3c85918b 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -54,11 +54,13 @@ extension DeploymentDescriptor { var eventSources = self.eventSources(name) // do we need to create a SQS queue ? Filter on SQSEvent source without Queue Arn - let sqsEventSources: [EventSource] = eventSources.filter{ - //FIXME: check if an queue Arn is provided - // according to https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - // ARN Regex is arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*) - $0.type == "SQS" // && TODO .... + let sqsEventSources: [EventSource] = eventSources.filter{ eventSource in + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + let queueName = (eventSource.properties as? SQSEventProperties)?.queue + return + eventSource.type == "SQS" && + queueName?.range(of: arnRegex, options: .regularExpression) == nil } // for each of SQSEvent Source without queue Arn, add a SQS resource and modify the event source to point ot that new resource From 332fdb6cd3855cc984d5b4c037bb0cb05463c61e Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 31 Dec 2022 17:07:53 +0100 Subject: [PATCH 09/79] ad unit test --- .../DeploymentDescriptorTests.swift | 299 ++++++++++++++++++ .../MockedDeploymentDescriptor.swift | 50 +++ 2 files changed, 349 insertions(+) create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift new file mode 100644 index 00000000..bc7fdee0 --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -0,0 +1,299 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2018 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 + +final class DeploymentDescriptorTest: XCTestCase { + + var originalCommandLineArgs: [String] = [] + + override func setUp() { + // save the original Command Line args, just in case + originalCommandLineArgs = CommandLine.arguments + CommandLine.arguments = ["mocked_arg0", "TestLambda"] + } + override func tearDown() { + CommandLine.arguments = originalCommandLineArgs + } + + func testSAMHeader() { + + // given + let expected = """ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: A SAM template to deploy a Swift Lambda function +""" + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [] } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testLambdaFunctionResource() { + + // given +#if arch(arm64) + let architecture = "arm64" +#else + let architecture = "x86_64" +#endif + let expected = """ +Resources: + TestLambda: + Type: AWS::Serverless::Function + Properties: + Architectures: + - \(architecture) + Handler: Provided + Runtime: provided.al2 + CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/TestLambda/TestLambda.zip + AutoPublishAlias: Live + Events: {} +""" + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [] } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testSimpleTableResource() { + + // given + let expected = """ +LogicalTestTable: + Type: AWS::Serverless::SimpleTable + Properties: + TableName: TestTable + PrimaryKey: + Name: pk + Type: String +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [] } + testDeployment.additionalResourcesFunction = { + return [Resource.simpleTable(name: "LogicalTestTable", + properties: SimpleTableProperties(primaryKey: .init(name: "pk", + type: "String"), + tableName: "TestTable") + )] + } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testSQSQueueResource() { + + // given + let expected = """ + LogicalQueueName: + Type: AWS::SQS::Queue + Properties: + QueueName: queue-name +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [] } + testDeployment.additionalResourcesFunction = { + return [Resource.sqsQueue(name: "LogicalQueueName", + properties: SQSResourceProperties(queueName: "queue-name")) + ] + } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testHttpApiEventSourceCatchAll() { + + // given + let expected = """ + Events: + HttpApiEvent: + Type: HttpApi +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [ .httpApi() ] } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testHttpApiEventSourceSpecific() { + + // given + let expected = """ + HttpApiEvent: + Type: HttpApi + Properties: + Method: GET + Path: /test +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [ .httpApi(method: .GET, + path: "/test") ] } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testSQSEventSourceWithArn() { + + // given + let expected = """ + Events: + SQSEvent: + Type: SQS + Properties: + Queue: arn:aws:sqs:eu-central-1:012345678901:lambda-test +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testSQSEventSourceWithoutArn() { + + // given + let expected = """ + Events: + SQSEvent: + Type: SQS + Properties: + Queue: + Fn::GetAtt: + - QueueQueueLambdaTest + - Arn +""" + let expectedResource = """ + QueueQueueLambdaTest: + Type: AWS::SQS::Queue + Properties: + QueueName: queue-lambda-test +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } + testDeployment.eventSourceFunction = { _ in [ .sqs(queue: "queue-lambda-test") ] } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expected)) + XCTAssertTrue(samYaml.contains(expectedResource)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + + } + + func testEnvironmentVariables() { + + // given + let expectedOne = """ + Environment: + Variables: + TEST2_VAR: TEST2_VALUE + TEST1_VAR: TEST1_VALUE +""" + let expectedTwo = """ + Environment: + Variables: + TEST1_VAR: TEST1_VALUE + TEST2_VAR: TEST2_VALUE +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in EnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", + "TEST2_VAR": "TEST2_VALUE"]) } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expectedOne) || samYaml.contains(expectedTwo)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift new file mode 100644 index 00000000..7d522de5 --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -0,0 +1,50 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2018 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 AWSLambdaDeploymentDescriptor + +struct MockDeploymentDescriptor: DeploymentDescriptor { + + var eventSourceFunction: ( (String) -> [EventSource] )? + var environmentVariableFunction: ( (String) -> EnvironmentVariable )? + var additionalResourcesFunction: ( () -> [Resource] )? + + // returns the event sources for the given Lambda function + func eventSources(_ lambdaName: String) -> [EventSource] { + if let eventSourceFunction { + return eventSourceFunction(lambdaName) + } else { + return [] + } + } + + // returns environment variables to associate with the given Lambda function + func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { + if let environmentVariableFunction { + return environmentVariableFunction(lambdaName) + } else { + return EnvironmentVariable.none() + } + } + + // returns environment variables to associate with the given Lambda function + func addResource() -> [Resource] { + if let additionalResourcesFunction { + return additionalResourcesFunction() + } else { + return [] + } + } +} From 00029af3e2389e3fbf88b04c7e8b54cd3df7956d Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 31 Dec 2022 17:15:49 +0100 Subject: [PATCH 10/79] add test target to Package.swift --- Package.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Package.swift b/Package.swift index 2767c324..30ebb45e 100644 --- a/Package.swift +++ b/Package.swift @@ -80,6 +80,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"), From ae418977e5d506bc5f06bc91edbde459dd6890f9 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 2 Jan 2023 11:25:10 +0100 Subject: [PATCH 11/79] simplify code and factorise functions --- .../DeploymentDefinition.swift | 5 + .../DeploymentDescriptor.swift | 124 +++++++++--------- 2 files changed, 64 insertions(+), 65 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 7ba1fe70..e482e6e3 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -63,6 +63,8 @@ public struct Resource: SAMResource { let properties: SAMResourceProperties let name: String + public static func none() -> [Resource] { return [] } + enum CodingKeys: String, CodingKey { case type = "Type" case properties = "Properties" @@ -170,6 +172,7 @@ public struct EnvironmentVariable: Codable { } public static func none() -> EnvironmentVariable { return EnvironmentVariable([:]) } public func isEmpty() -> Bool { return variables.count == 0 } + enum CodingKeys: String, CodingKey { case variables = "Variables" } @@ -195,6 +198,8 @@ public struct EventSource: SAMEvent { lhs.type == rhs.type && lhs.name == rhs.name } + public static func none() -> [EventSource] { return [] } + // this is to make the compiler happy : Resource now confoms to Encodable public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 3c85918b..5017c567 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -40,62 +40,15 @@ extension DeploymentDescriptor { // - it uses some default values (like the ZIP file produced by archive command) public func deploymentDefinition() -> DeploymentDefinition { - var additionalressources : [Resource] = [] - - // create function resources for each Lambda function + // Create function resources for each Lambda function var resources = self.lambdaNames().map { name in + return createServerlessFunctionResource(name) // returns [Resource] + }.flatMap{ $0 } // convert [[Resource]] to [Resource] - // default output dir for archive plugin is - // .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/HttpApiLambda/HttpApiLambda.zip - // FIXME: what if use used --output-path option on packager ? - let package = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" - - // add event sources provided by Lambda developer - var eventSources = self.eventSources(name) - - // do we need to create a SQS queue ? Filter on SQSEvent source without Queue Arn - let sqsEventSources: [EventSource] = eventSources.filter{ eventSource in - // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# - let queueName = (eventSource.properties as? SQSEventProperties)?.queue - return - eventSource.type == "SQS" && - queueName?.range(of: arnRegex, options: .regularExpression) == nil - } - - // for each of SQSEvent Source without queue Arn, add a SQS resource and modify the event source to point ot that new resource - for sqsES in sqsEventSources { - - // add a queue resource to the SAM teamplate - guard let queueName = (sqsES.properties as? SQSEventProperties)?.queue else { - fatalError("SQS Event Source's properties is not a SAMEventProperties") - } - let logicalName = logicalName(resourceType: "Queue", - resourceName: queueName) - additionalressources.append(Resource.sqsQueue(name: logicalName, - properties: SQSResourceProperties(queueName: queueName))) - - // replace the event source to point to this new queue resource - eventSources.removeAll{ $0 == sqsES } - eventSources.append(EventSource.sqs(queue: "!GetAtt \(logicalName).Arn")) - - } - - // finally, let's build the function definition - return Resource.serverlessFunction(name: name, - codeUri: package, - eventSources: eventSources, - environment: environmentVariables(name)) - - } - - // add newly created resources (non-functions) - resources.append(contentsOf: additionalressources) - // add developer-provided resources resources.append(contentsOf: self.addResource()) - // craete the SAM deployment decsriptor + // create the SAM deployment descriptor return SAMDeployment(resources: resources) } @@ -108,7 +61,7 @@ extension DeploymentDescriptor { // returns additional resources to create in the cloud (ex : a dynamoDB table) // This is a default implementation to avoid forcing the lambda developer to implement it when not needed public func addResource() -> [Resource] { - return [] + return Resource.none() } // entry point and main function. Lambda developer must call this function. @@ -123,7 +76,7 @@ extension DeploymentDescriptor { // The lambda function names that are passed as command line argument // it is used to infer the directory and the name of the ZIP files - public func lambdaNames() -> [String] { + private func lambdaNames() -> [String] { if CommandLine.arguments.count < 2 { fatalError( "You must pass the AWS Lambda function names as list of arguments\n\nFor example: ./deploy LambdaOne LambdaTwo" @@ -134,6 +87,7 @@ extension DeploymentDescriptor { } // generate the YAML version of the deployment descriptor + // keep the method public for testability public func toYaml() throws -> String { let deploy = deploymentDefinition() @@ -144,27 +98,67 @@ extension DeploymentDescriptor { throw DeploymentEncodingError.yamlError(causedBy: error) } } - - // generate the JSON version of the deployment descriptor - public func toJson() throws -> String { - let deploy = deploymentDefinition() + + private func createServerlessFunctionResource(_ name: String) -> [Resource] { - do { - let jsonData = try JSONEncoder().encode(deploy) - guard let json = String(data: jsonData, encoding: .utf8) else { - throw DeploymentEncodingError.stringError(causedBy: jsonData) + // the default output dir for archive plugin + // FIXME: add support for --output-path option on packager + let package = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" + + // add event sources provided by Lambda developer + var lambdaEventSources = eventSources(name) + + // do we need to create a SQS queue ? When SQS event source is specified, the Lambda function developer + // might give a queue name or a queue Arn. When developer gives just a queue name, + // we add a resource to create the queue and reference this new resource as event source + var ressources : [Resource] = [] + + // First, filter on SQSEvent source **without** Queue Arn + let sqsEventSources: [EventSource] = lambdaEventSources.filter{ lambdaEventSource in + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + let queueName = (lambdaEventSource.properties as? SQSEventProperties)?.queue + return lambdaEventSource.type == "SQS" && + queueName?.range(of: arnRegex, options: .regularExpression) == nil + } + + // Second, for each SQSEvent source without queue Arn, + // create a SQS resource and modify the event source to point to that new resource + for sqsES in sqsEventSources { + + // add a queue resource to the SAM teamplate + guard let queueName = (sqsES.properties as? SQSEventProperties)?.queue else { + fatalError("SQS Event Source's properties is not a SAMEventProperties or there is no queue name") } - return json - } catch { - throw DeploymentEncodingError.jsonError(causedBy: error) + // create a logical name for the queue name + let logicalName = logicalName(resourceType: "Queue", + resourceName: queueName) + ressources.append(Resource.sqsQueue(name: logicalName, + properties: SQSResourceProperties(queueName: queueName))) + + // replace the event source to point to this new queue resource + lambdaEventSources.removeAll{ $0 == sqsES } + lambdaEventSources.append(EventSource.sqs(queue: "!GetAtt \(logicalName).Arn")) + } + + + // finally, let's build the function definition + let serverlessFunction = Resource.serverlessFunction(name: name, + codeUri: package, + eventSources: lambdaEventSources, + environment: environmentVariables(name)) + + // put all resources together + ressources.append(serverlessFunction) + return ressources } // Transform resourceName : // remove space // remove hyphen // camel case - func logicalName(resourceType: String, resourceName: String) -> String { + private 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: "") From fef8bb210d898b31293b02cbfcd5afefbc00ade0 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 3 Jan 2023 09:19:36 +0100 Subject: [PATCH 12/79] simplify API : allow to pass Resource to SQSEventSource --- .../DeploymentDefinition.swift | 135 +++++++++++++----- .../DeploymentDescriptor.swift | 50 ++----- .../DeploymentDescriptorTests.swift | 123 +++++++++------- 3 files changed, 182 insertions(+), 126 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index e482e6e3..921d8748 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -57,7 +57,7 @@ public struct SAMDeployment: DeploymentDefinition { public protocol SAMResource: Encodable {} public protocol SAMResourceProperties: Encodable {} -public struct Resource: SAMResource { +public struct Resource: SAMResource, Equatable { let type: String let properties: SAMResourceProperties @@ -65,6 +65,10 @@ public struct Resource: SAMResource { public static func none() -> [Resource] { return [] } + public static func == (lhs: Resource, rhs: Resource) -> Bool { + lhs.type == rhs.type && lhs.name == rhs.name + } + enum CodingKeys: String, CodingKey { case type = "Type" case properties = "Properties" @@ -166,13 +170,17 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { LOG_LEVEL: debug */ public struct EnvironmentVariable: Codable { - public let variables: [String:String] + public var variables: [String:String] public init(_ variables: [String:String]) { self.variables = variables } public static func none() -> EnvironmentVariable { return EnvironmentVariable([:]) } public func isEmpty() -> Bool { return variables.count == 0 } + public mutating func append(_ key: String, _ value: String) { + variables[key] = value + } + enum CodingKeys: String, CodingKey { case variables = "Variables" } @@ -270,15 +278,28 @@ public enum HttpVerb: String, Encodable { -----------------------------------------------------------------------------------------*/ extension EventSource { - public static func sqs(name: String = "SQSEvent", - queue: String) -> EventSource { - - let properties = SQSEventProperties(queue) + private static func sqs(name: String = "SQSEvent", + properties: SQSEventProperties) -> EventSource { return EventSource(type: "SQS", properties: properties, name: name) } + public static func sqs(name: String = "SQSEvent", + queue queueRef: String) -> EventSource { + + let properties = SQSEventProperties(byRef: queueRef) + return EventSource.sqs(name: name, + properties: properties) + } + + public static func sqs(name: String = "SQSEvent", + queue: Resource) -> EventSource { + + let properties = SQSEventProperties(queue) + return EventSource.sqs(name: name, + properties: properties) + } } /** @@ -287,45 +308,41 @@ extension EventSource { */ public struct SQSEventProperties: SAMEventProperties, Equatable { - private var _queue: String = "" - - // Change encoding when queueName starts with !GetAtt - it should be - // Fn::GetAtt: [ logicalNameOfResource, attributeName ] - // doc : https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html - public var queue: String { - get { - return _queue - } - set(newQueue) { - if newQueue.starts(with: "!") { - let elements = newQueue.split(separator: " ") - guard elements.count == 2 else { - fatalError("Invalid intrisic function: \(newQueue), only one space allowed") - } - let key = String(elements[0]).replacingOccurrences(of: "!", with: "Fn::") - self.intrisicFunction[key] = elements[1].split(separator: ".").map{ String($0) } - } else { - self._queue = newQueue - } - } - } - var intrisicFunction: [String:[String]] = [:] + public var queueByArn: String? = nil + public var queue: Resource? = nil - public init(_ queue: String) { - self.queue = queue + init(byRef ref: String) { + + // 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)?.arn { + self.queueByArn = arn + } else { + let logicalName = Resource.logicalName(resourceType: "Queue", + resourceName: ref) + self.queue = Resource.sqsQueue(name: logicalName, + properties: SQSResourceProperties(queueName: ref)) + } + } + init(_ queue: Resource) { self.queue = queue } + enum CodingKeys: String, CodingKey { - case _queue = "Queue" + case queue = "Queue" } + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - if intrisicFunction.isEmpty { - try container.encode(self.queue, forKey: ._queue) - } else { - try container.encode(intrisicFunction, forKey: ._queue) + + // 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 + if let queueByArn { + try container.encode(queueByArn, forKey: .queue) + } else if let queue { + var getAttIntrinsicFunction: [String:[String]] = [:] + getAttIntrinsicFunction["Fn::GetAtt"] = [ queue.name, "Arn"] + try container.encode(getAttIntrinsicFunction, forKey: .queue) } } - } //MARK: SQS queue resource definition @@ -344,6 +361,12 @@ extension Resource { name: name) } + public static func sqsQueue(logicalName: String, + physicalName: String ) -> Resource { + + let sqsProperties = SQSResourceProperties(queueName: physicalName) + return sqsQueue(name: logicalName, properties: sqsProperties) + } } public struct SQSResourceProperties: SAMResourceProperties { @@ -369,8 +392,14 @@ extension Resource { properties: properties, name: name) } - -} + public static func simpleTable(logicalName: String, + physicalname: String, + primaryKeyName: String, + primaryKeyValue: String) -> Resource { + let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyValue) + let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalname) + return simpleTable(name: logicalName, properties: properties) + }} public struct SimpleTableProperties: SAMResourceProperties { let primaryKey: PrimaryKey @@ -407,6 +436,34 @@ public struct SimpleTableProperties: SAMResourceProperties { } } + +//MARK: Utils + +struct Arn { + public let arn: String + init?(_ arn: String) { + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + if arn.range(of: arnRegex, options: .regularExpression) != nil { + self.arn = arn + } else { + return nil + } + } +} + +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 + } +} + public enum DeploymentEncodingError: Error { case yamlError(causedBy: Error) case jsonError(causedBy: Error) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 5017c567..0cd7607c 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -106,43 +106,19 @@ extension DeploymentDescriptor { let package = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" // add event sources provided by Lambda developer - var lambdaEventSources = eventSources(name) + let lambdaEventSources = eventSources(name) - // do we need to create a SQS queue ? When SQS event source is specified, the Lambda function developer - // might give a queue name or a queue Arn. When developer gives just a queue name, - // we add a resource to create the queue and reference this new resource as event source - var ressources : [Resource] = [] + // 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 name or a queue resource, + // the event source eventually creates the queue resource and references the resource. + // Now, we need to collect all queue resources created by SQS event sources or passed by Lambda function develper + // to add them to the list of resources to synthetize + var resources : [Resource] = lambdaEventSources.filter{ lambdaEventSource in + lambdaEventSource.type == "SQS" && + (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil + }.compactMap { sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue } - // First, filter on SQSEvent source **without** Queue Arn - let sqsEventSources: [EventSource] = lambdaEventSources.filter{ lambdaEventSource in - // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# - let queueName = (lambdaEventSource.properties as? SQSEventProperties)?.queue - return lambdaEventSource.type == "SQS" && - queueName?.range(of: arnRegex, options: .regularExpression) == nil - } - - // Second, for each SQSEvent source without queue Arn, - // create a SQS resource and modify the event source to point to that new resource - for sqsES in sqsEventSources { - - // add a queue resource to the SAM teamplate - guard let queueName = (sqsES.properties as? SQSEventProperties)?.queue else { - fatalError("SQS Event Source's properties is not a SAMEventProperties or there is no queue name") - } - // create a logical name for the queue name - let logicalName = logicalName(resourceType: "Queue", - resourceName: queueName) - ressources.append(Resource.sqsQueue(name: logicalName, - properties: SQSResourceProperties(queueName: queueName))) - - // replace the event source to point to this new queue resource - lambdaEventSources.removeAll{ $0 == sqsES } - lambdaEventSources.append(EventSource.sqs(queue: "!GetAtt \(logicalName).Arn")) - - } - - // finally, let's build the function definition let serverlessFunction = Resource.serverlessFunction(name: name, codeUri: package, @@ -150,8 +126,8 @@ extension DeploymentDescriptor { environment: environmentVariables(name)) // put all resources together - ressources.append(serverlessFunction) - return ressources + resources.append(serverlessFunction) + return resources } // Transform resourceName : diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index bc7fdee0..067e83c5 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -16,9 +16,9 @@ import XCTest final class DeploymentDescriptorTest: XCTestCase { - + var originalCommandLineArgs: [String] = [] - + override func setUp() { // save the original Command Line args, just in case originalCommandLineArgs = CommandLine.arguments @@ -27,9 +27,9 @@ final class DeploymentDescriptorTest: XCTestCase { override func tearDown() { CommandLine.arguments = originalCommandLineArgs } - + func testSAMHeader() { - + // given let expected = """ AWSTemplateFormatVersion: '2010-09-09' @@ -39,20 +39,20 @@ Description: A SAM template to deploy a Swift Lambda function var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testLambdaFunctionResource() { - + // given #if arch(arm64) let architecture = "arm64" @@ -75,20 +75,20 @@ Resources: var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testSimpleTableResource() { - + // given let expected = """ LogicalTestTable: @@ -99,31 +99,31 @@ LogicalTestTable: Name: pk Type: String """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [] } testDeployment.additionalResourcesFunction = { return [Resource.simpleTable(name: "LogicalTestTable", - properties: SimpleTableProperties(primaryKey: .init(name: "pk", - type: "String"), - tableName: "TestTable") - )] + properties: SimpleTableProperties(primaryKey: .init(name: "pk", + type: "String"), + tableName: "TestTable") + )] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testSQSQueueResource() { - + // given let expected = """ LogicalQueueName: @@ -131,7 +131,7 @@ LogicalTestTable: Properties: QueueName: queue-name """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [] } @@ -140,44 +140,44 @@ LogicalTestTable: properties: SQSResourceProperties(queueName: "queue-name")) ] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testHttpApiEventSourceCatchAll() { - + // given let expected = """ Events: HttpApiEvent: Type: HttpApi """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [ .httpApi() ] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testHttpApiEventSourceSpecific() { - + // given let expected = """ HttpApiEvent: @@ -186,25 +186,25 @@ LogicalTestTable: Method: GET Path: /test """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [ .httpApi(method: .GET, path: "/test") ] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testSQSEventSourceWithArn() { - + // given let expected = """ Events: @@ -213,24 +213,24 @@ LogicalTestTable: Properties: Queue: arn:aws:sqs:eu-central-1:012345678901:lambda-test """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) } catch { XCTFail("toYaml should not throw an exceptoon") } } - + func testSQSEventSourceWithoutArn() { - + // given let expected = """ Events: @@ -248,26 +248,26 @@ LogicalTestTable: Properties: QueueName: queue-lambda-test """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [ .sqs(queue: "queue-lambda-test") ] } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expected)) XCTAssertTrue(samYaml.contains(expectedResource)) } catch { XCTFail("toYaml should not throw an exceptoon") } - + } - + func testEnvironmentVariables() { - + // given let expectedOne = """ Environment: @@ -281,19 +281,42 @@ LogicalTestTable: TEST1_VAR: TEST1_VALUE TEST2_VAR: TEST2_VALUE """ - + var testDeployment = MockDeploymentDescriptor() testDeployment.environmentVariableFunction = { _ in EnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", "TEST2_VAR": "TEST2_VALUE"]) } - + do { // when let samYaml = try testDeployment.toYaml() - + // then XCTAssertTrue(samYaml.contains(expectedOne) || samYaml.contains(expectedTwo)) } catch { XCTFail("toYaml should not throw an exceptoon") } } + + 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) + } + } From b3ce4ca768d067f6a57fc7ddd00fb272b1f4b3a8 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 3 Jan 2023 13:05:50 +0100 Subject: [PATCH 13/79] add different type of env variable + refactor resource names --- .../DeploymentDefinition.swift | 95 ++++++++++++---- .../DeploymentDescriptorTests.swift | 102 ++++++++++++++++-- 2 files changed, 169 insertions(+), 28 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 921d8748..cb323366 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -169,21 +169,76 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { Variables: LOG_LEVEL: debug */ -public struct EnvironmentVariable: Codable { - public var variables: [String:String] +public struct EnvironmentVariable: Encodable { + + public var variables: [String:EnvironmentVariableValue] = [:] + public init() {} public init(_ variables: [String:String]) { - self.variables = variables + for key in variables.keys { + self.variables[key] = .string(value: variables[key] ?? "") + } } public static func none() -> EnvironmentVariable { return EnvironmentVariable([:]) } public func isEmpty() -> Bool { return variables.count == 0 } public mutating func append(_ key: String, _ value: String) { - variables[key] = value + 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: String, CodingKey { case variables = "Variables" } + + public func encode(to encoder: Encoder) throws { + 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 EnvironmentVariableValue { + // KEY: VALUE + case string(value: String) + + // KEY: + // Ref: VALUE + case array(value: [String:String]) + + // KEY: + // Fn::GetAtt: + // - VALUE1 + // - VALUE2 + case dictionary(value: [String:[String]]) + } + + private 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: Lambda Function event source @@ -319,8 +374,8 @@ public struct SQSEventProperties: SAMEventProperties, Equatable { } else { let logicalName = Resource.logicalName(resourceType: "Queue", resourceName: ref) - self.queue = Resource.sqsQueue(name: logicalName, - properties: SQSResourceProperties(queueName: ref)) + self.queue = Resource.queue(name: logicalName, + properties: SQSResourceProperties(queueName: ref)) } } @@ -353,19 +408,19 @@ public struct SQSEventProperties: SAMEventProperties, Equatable { https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html -----------------------------------------------------------------------------------------*/ extension Resource { - public static func sqsQueue(name: String, - properties: SQSResourceProperties) -> Resource { + public static func queue(name: String, + properties: SQSResourceProperties) -> Resource { return Resource(type: "AWS::SQS::Queue", properties: properties, name: name) } - public static func sqsQueue(logicalName: String, - physicalName: String ) -> Resource { + public static func queue(logicalName: String, + physicalName: String ) -> Resource { let sqsProperties = SQSResourceProperties(queueName: physicalName) - return sqsQueue(name: logicalName, properties: sqsProperties) + return queue(name: logicalName, properties: sqsProperties) } } @@ -385,20 +440,20 @@ public struct SQSResourceProperties: SAMResourceProperties { -----------------------------------------------------------------------------------------*/ extension Resource { - public static func simpleTable(name: String, + public static func table(name: String, properties: SimpleTableProperties) -> Resource { return Resource(type: "AWS::Serverless::SimpleTable", properties: properties, name: name) } - public static func simpleTable(logicalName: String, - physicalname: String, - primaryKeyName: String, - primaryKeyValue: String) -> Resource { - let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyValue) - let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalname) - return simpleTable(name: logicalName, properties: properties) + public static func table(logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String) -> Resource { + let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) + let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) + return table(name: logicalName, properties: properties) }} public struct SimpleTableProperties: SAMResourceProperties { diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 067e83c5..8be54824 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -104,11 +104,10 @@ LogicalTestTable: testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [] } testDeployment.additionalResourcesFunction = { - return [Resource.simpleTable(name: "LogicalTestTable", - properties: SimpleTableProperties(primaryKey: .init(name: "pk", - type: "String"), - tableName: "TestTable") - )] + return [Resource.table(logicalName: "LogicalTestTable", + physicalName: "TestTable", + primaryKeyName: "pk", + primaryKeyType: "String")] } do { @@ -136,8 +135,8 @@ LogicalTestTable: testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } testDeployment.eventSourceFunction = { _ in [] } testDeployment.additionalResourcesFunction = { - return [Resource.sqsQueue(name: "LogicalQueueName", - properties: SQSResourceProperties(queueName: "queue-name")) + return [Resource.queue(name: "LogicalQueueName", + properties: SQSResourceProperties(queueName: "queue-name")) ] } @@ -266,7 +265,7 @@ LogicalTestTable: } - func testEnvironmentVariables() { + func testEnvironmentVariablesString() { // given let expectedOne = """ @@ -297,6 +296,93 @@ LogicalTestTable: } } + func testEnvironmentVariablesArray() { + + // given + let expectedOne = """ + Environment: + Variables: + TEST1_VAR: + Ref: TEST1_VALUE +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in + var envVars = EnvironmentVariable() + envVars.append("TEST1_VAR", ["Ref" : "TEST1_VALUE"]) + return envVars + } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expectedOne)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testEnvironmentVariablesDictionary() { + + // given + let expectedOne = """ + Environment: + Variables: + TEST1_VAR: + Fn::GetAtt: + - TEST1_VALUE + - Arn +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in + var envVars = EnvironmentVariable() + envVars.append("TEST1_VAR", ["Fn::GetAtt" : ["TEST1_VALUE", "Arn"]]) + return envVars + } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expectedOne)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + + func testEnvironmentVariablesResource() { + + // given + let expectedOne = """ + Environment: + Variables: + TEST1_VAR: + Ref: LogicalName +""" + + var testDeployment = MockDeploymentDescriptor() + testDeployment.environmentVariableFunction = { _ in + let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") + var envVars = EnvironmentVariable() + envVars.append("TEST1_VAR", resource) + return envVars + } + + do { + // when + let samYaml = try testDeployment.toYaml() + + // then + XCTAssertTrue(samYaml.contains(expectedOne)) + } catch { + XCTFail("toYaml should not throw an exceptoon") + } + } + func testArnOK() { // given let validArn = "arn:aws:sqs:eu-central-1:012345678901:lambda-test" From 639d96d59f36ebb6b25cbf31bc3859e978038f59 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 3 Jan 2023 13:13:19 +0100 Subject: [PATCH 14/79] add more example to create the deployment descriptor --- Examples/SAM/Deploy/Deploy.swift | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/Examples/SAM/Deploy/Deploy.swift b/Examples/SAM/Deploy/Deploy.swift index 57beedab..658caef8 100755 --- a/Examples/SAM/Deploy/Deploy.swift +++ b/Examples/SAM/Deploy/Deploy.swift @@ -3,29 +3,76 @@ import Foundation @main public struct HttpApiLambdaDeployment: DeploymentDescriptor { + + // you have to include a main() method to generate the deployment descriptor static func main() throws { HttpApiLambdaDeployment().run() } + // optional, example of a shared resources between multiple Lambda functions + var queue : Resource + var table : Resource + init() { + self.queue = Resource.queue(logicalName: "SharedQueue", + physicalName: "swift-lambda-shared-queue") + self.table = Resource.table(logicalName: "SwiftLambdaTable", + physicalName: "swift-lambda-table", + primaryKeyName: "id", + primaryKeyType: "String") + } + + // optional, define the event sources for each Lambda function public func eventSources(_ lambdaName: String) -> [EventSource] { if lambdaName == "HttpApiLambda" { + // example of a Lambda function exposed through a REST API return [ + + // this defines a catch all API (for all HTTP verbs and paths) .httpApi() + + // this defines a REST API for HTTP verb GET and path / test // .httpApi(method: .GET, path: "/test"), ] } else if lambdaName == "SQSLambda" { - return [.sqs(queue: "swift-lambda-test")] + + // example of a Lambda function triggered when a message arrive on a queue + + // this will create a new queue resource + // return [.sqs(queue: "swift-lambda-test")] + + // this will reference an existing queue + // return [.sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test")] + + // this will reference a queue resource created in this deployment descriptor + return [.sqs(queue: self.queue)] } else { fatalError("Unknown Lambda name : \(lambdaName)") } } + // optional, define the environment variables for each Lambda function public func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { - // return the same env variables for all functions - return EnvironmentVariable([ "LOG_LEVEL": "debug" ]) + + // an environment variable for all functions + var envVariables = EnvironmentVariable([ "LOG_LEVEL": "debug" ]) + + // variables specific for one Lambda function + if (lambdaName == "HttpApiLambda") { + // pass a reference to the shared queue and the DynamoDB table + envVariables.append("QUEUE_URL", self.queue) + envVariables.append("DYNAMO_TABLE", self.table) + } + + return envVariables + } + + // optional, additional resources to create on top of the Lambda functions and their direct dependencies + // in this example, I create a DynamoDB Table + public func addResource() -> [Resource] { + return [self.table] } } From fbd0b9539be8e531cb394f49066da7d44e40b167 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 3 Jan 2023 13:24:07 +0100 Subject: [PATCH 15/79] removed unused portion of code --- .../DeploymentDescriptor.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 0cd7607c..471fa40f 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -129,15 +129,4 @@ extension DeploymentDescriptor { resources.append(serverlessFunction) return resources } - - // Transform resourceName : - // remove space - // remove hyphen - // camel case - private 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 - } } From 61d9c51d1dc22f18b70a2fe58aa27808ddd9df75 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 25 Jan 2023 14:48:46 +0100 Subject: [PATCH 16/79] fix an issue that prevent to identify the correct path for Deploy exec --- Plugins/AWSLambdaDeployer/Plugin.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index ce74c7e9..fdeb6229 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -252,15 +252,23 @@ private struct Configuration: CustomStringConvertible { for t in deployProducts[0].targets { print("\(t.name) - \(t.directory)") } + guard deployProducts[0].targets.count == 1 else { + fatalError("There is more than one Deploy target \(deployProducts[0].targets)") + } #if arch(arm64) let arch = "arm64-apple-macosx" #else let arch = "x86_64-apple-macosx" #endif - self.deployExecutable = URL(fileURLWithPath: deployProducts[0].targets[0].directory.string) - .deletingLastPathComponent() - .appendingPathComponent(".build/\(arch)/\(self.buildConfiguration)/Deploy") + var deployExecutableURL = URL(fileURLWithPath: deployProducts[0].targets[0].directory.string) + // while there is /Sources or /Deploy => remove last path component + while deployExecutableURL.path.contains("/Sources") || + deployExecutableURL.path.contains("/Deploy") { + deployExecutableURL = deployExecutableURL.deletingLastPathComponent() + } + self.deployExecutable = deployExecutableURL.appendingPathComponent(".build/\(arch)/\(self.buildConfiguration)/Deploy") + if self.verboseLogging { print("-------------------------------------------------------------------------") print("configuration") From e52d196f8c6a1dcf98569c0f7f1ea4e74c2e8ed1 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 30 Jan 2023 22:45:37 +0100 Subject: [PATCH 17/79] new version of the deployer plugin. Now it uses a declarative interface to describe the deployment instead of a programmatic one. --- Examples/SAM/Deploy.swift | 68 ++ Examples/SAM/Makefile | 31 + Examples/SAM/Package.swift | 21 +- Package.swift | 6 +- Plugins/AWSLambdaDeployer/Plugin.swift | 540 +++++++++------- .../DeploymentDefinition.swift | 598 +++--------------- .../DeploymentDescriptor.swift | 579 ++++++++++++++--- 7 files changed, 1014 insertions(+), 829 deletions(-) create mode 100644 Examples/SAM/Deploy.swift create mode 100644 Examples/SAM/Makefile diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift new file mode 100644 index 00000000..c8848234 --- /dev/null +++ b/Examples/SAM/Deploy.swift @@ -0,0 +1,68 @@ +import AWSLambdaDeploymentDescriptor + +// example of a shared resource +let sharedQueue : Resource = Resource.queue(logicalName: "SharedQueue", + physicalName: "swift-lambda-shared-queue") + +// example of common environment variables +let sharedEnvironementVariables = ["LOG_LEVEL":"debug"] + +let _ = DeploymentDefinition( + + description: "Working SAM template for Swift Lambda function", + + functions: [ + + .function( + // the name of the function + name: "HttpApiLambda", + + // the event sources + eventSources: [ + // example of a catch all API +// .httpApi(), + + // example of an API for a specific path and specific http verb + .httpApi(method: .GET, path: "/test"), + ], + + // optional environment variables - one variable + //environment: .variable("NAME","VALUE") + + // optional environment variables - multiple environment variables at once + environment: .variable([sharedEnvironementVariables, ["NAME2":"VALUE2"]]) + ), + + .function( + name: "SQSLambda", + eventSources: [ + // this will reference an existing queue +// .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:swift-lambda-shared-queue"), + + // this will create a new queue resource + .sqs(queue: "swift-lambda-queue-name"), + + // this will create a new queue resource +// .sqs(queue: .queue(logicalName: "LambdaQueueResource", physicalName: "swift-lambda-queue-resource")) + + // this will reference a queue resource created in this deployment descriptor +// .sqs(queue: sharedQueue) + ], + environment: .variable(sharedEnvironementVariables) + ) + ], + + // additional resources + 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") + ] +) diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile new file mode 100644 index 00000000..b663eb68 --- /dev/null +++ b/Examples/SAM/Makefile @@ -0,0 +1,31 @@ +build: + swift build + +update: + swift package update + +release: + swift build -c release + +archive: build + swift package --disable-sandbox archive + +deploy: build + swift package --disable-sandbox deploy --configuration debug + +nodeploy: build + swift package --disable-sandbox deploy --verbose --nodeploy --configuration debug + +clean: + # find . -name .build -exec rm -rf {} \; + # find . -name .swiftpm -exec rm -rf {} \; + # find . -name dist -exec rm -rf {} \; + rm -rf ../../.build + rm -rf .build + +test-generate: + /usr/bin/swift \ + -L .build/debug \ + -I .build/debug \ + -lAWSLambdaDeploymentDescriptor \ + ./Deploy.swift diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index 58b9cd84..6e7e2a9f 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -16,6 +16,11 @@ import PackageDescription +var deploymentDescriptorDependency : [Target.Dependency] = [] +#if !os(Linux) + deploymentDescriptorDependency = [.product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime")] +#endif + let package = Package( name: "swift-aws-lambda-runtime-example", platforms: [ @@ -24,30 +29,22 @@ let package = Package( products: [ .executable(name: "HttpApiLambda", targets: ["HttpApiLambda"]), .executable(name: "SQSLambda", targets: ["SQSLambda"]), - // this generate the AWS SAM template for deployment. It is called by the deployment plugin (swift package deploy) - .executable(name: "Deploy", targets: ["Deploy"]) ], dependencies: [ // this is the dependency on the swift-aws-lambda-runtime library // in real-world projects this would say - // .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), +// .package(url: "https://github.com/sebsto/swift-aws-lambda-runtime.git", branch: "sebsto/deployerplugin"), +// .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), .package(name: "swift-aws-lambda-runtime", path: "../.."), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") ], targets: [ - .executableTarget( - name: "Deploy", - dependencies: [ - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") - ], - path: "./Deploy" - ), .executableTarget( name: "HttpApiLambda", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") - ], + ] + deploymentDescriptorDependency, path: "./HttpApiLambda" ), .executableTarget( @@ -55,7 +52,7 @@ let package = Package( dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") - ], + ] + deploymentDescriptorDependency, path: "./SQSLambda" ) ] diff --git a/Package.swift b/Package.swift index 30ebb45e..7a3277fd 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( // 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", targets: ["AWSLambdaDeploymentDescriptor"]), + .library(name: "AWSLambdaDeploymentDescriptor", type: .dynamic, targets: ["AWSLambdaDeploymentDescriptor"]), // for testing only .library(name: "AWSLambdaTesting", targets: ["AWSLambdaTesting"]), ], @@ -29,7 +29,6 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.4.2")), .package(url: "https://github.com/swift-server/swift-backtrace.git", .upToNextMajor(from: "1.2.3")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.1") ], targets: [ .target(name: "AWSLambdaRuntime", dependencies: [ @@ -56,9 +55,6 @@ let package = Package( ), .target( name: "AWSLambdaDeploymentDescriptor", - dependencies: [ - .product(name: "Yams", package: "Yams") - ], path: "Sources/AWSLambdaDeploymentDescriptor" ), .plugin( diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index fdeb6229..65f9b021 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -19,255 +19,262 @@ import PackagePlugin @main 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 deploy") + + // gather file paths + let samDeploymentDescriptorFilePath = "\(context.package.directory)/sam.json" + + 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: "Is SAM installed ? (brew tap aws/tap && brew install aws-sam-cli)", + verboseLogging: configuration.verboseLogging) + + // generate the deployment descriptor + try self.generateDeploymentDescriptor(projectDirectory: context.package.directory, + buildConfiguration: configuration.buildConfiguration, + swiftExecutable: swiftExecutablePath, + samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, + archivePath: configuration.archiveDirectory, + 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, + samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, + stackName : configuration.stackName, + verboseLogging: configuration.verboseLogging) } - - let samExecutable = try context.tool(named: "sam") - let samExecutableUrl = URL(fileURLWithPath: samExecutable.path.string) - let deploymentDescriptorExecutableUrl = configuration.deployExecutable - if configuration.verboseLogging { - print("-------------------------------------------------------------------------") - print("executables") - print("-------------------------------------------------------------------------") - print("SAM Executable : \(samExecutableUrl)") - print("Deployment Descriptor Executable : \(deploymentDescriptorExecutableUrl)") + } + + private func generateDeploymentDescriptor(projectDirectory: Path, + buildConfiguration: PackageManager.BuildConfiguration, + swiftExecutable: Path, + samDeploymentDescriptorFilePath: String, + archivePath: String, + verboseLogging: Bool) throws { + print("-------------------------------------------------------------------------") + print("Generating SAM deployment descriptor") + print("-------------------------------------------------------------------------") + + // + // Build and run the Deploy.swift package description + // this generates the SAM deployment decsriptor + // + 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 PluginError.deployswiftDoesNotExist } - let currentDirectory = FileManager.default.currentDirectoryPath - let samDeploymentDescriptorUrl = URL(fileURLWithPath: currentDirectory) - .appendingPathComponent("sam.yaml") do { - print("-------------------------------------------------------------------------") - print("generating SAM deployment descriptor") - configuration.verboseLogging ? print("\(samDeploymentDescriptorUrl)") : nil - print("-------------------------------------------------------------------------") - let samDeploymentDescriptor = try self.execute( - executable: deploymentDescriptorExecutableUrl, - arguments: configuration.products.compactMap { $0.name }, - logLevel: configuration.verboseLogging ? .debug : .silent) - try samDeploymentDescriptor.write( - to: samDeploymentDescriptorUrl, atomically: true, encoding: .utf8) - - print("-------------------------------------------------------------------------") - print("validating SAM deployment descriptor") - print("-------------------------------------------------------------------------") - try self.execute( - executable: samExecutableUrl, - arguments: ["validate", "-t", samDeploymentDescriptorUrl.path], - logLevel: configuration.verboseLogging ? .debug : .silent) - - if !configuration.noDeploy { + let cmd = [ + swiftExecutable.string, + "-L \(projectDirectory)/.build/\(buildConfiguration)/", + "-I \(projectDirectory)/.build/\(buildConfiguration)/", + "-l\(sharedLibraryName)", + deploymentDescriptorFilePath, + "--archive-path", archivePath + ] + let helperCmd = cmd.joined(separator: " \\\n") + + if verboseLogging { print("-------------------------------------------------------------------------") - print("deploying AWS Lambda function") + print("Swift compile and run Deploy.swift") print("-------------------------------------------------------------------------") - try self.execute( - executable: samExecutableUrl, - arguments: ["deploy", "-t", samDeploymentDescriptorUrl.path], - logLevel: configuration.verboseLogging ? .debug : .silent) - } - } catch Errors.processFailed(_, _) { - print("The generated SAM template is invalid or can not be deployed.") - if configuration.verboseLogging { - print("File at : \(samDeploymentDescriptorUrl)") - } else { - print("Run the command again with --verbose argument to receive more details.") + print("Swift command:\n\n\(helperCmd)\n") } + + // create and execute a plugin helper to run the "swift" command + let helperFilePath = "\(projectDirectory)/compile.sh" + let helperFileUrl = URL(fileURLWithPath: helperFilePath) + try helperCmd.write(to: helperFileUrl, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helperFilePath) + let samDeploymentDescriptor = try execute( + executable: Path("/bin/bash"), + arguments: ["-c", helperFilePath], + customWorkingDirectory: projectDirectory, + logLevel: verboseLogging ? .debug : .silent) + // running the swift command directly from the plugin does not work 🤷‍♂️ + // let samDeploymentDescriptor = try execute( + // executable: swiftExecutable.path, + // arguments: Array(cmd.dropFirst()), + // customWorkingDirectory: context.package.directory, + // logLevel: configuration.verboseLogging ? .debug : .silent) + try FileManager.default.removeItem(at: helperFileUrl) + + // write the generated SAM deployment decsriptor to disk + let samDeploymentDescriptorFileUrl = URL(fileURLWithPath: samDeploymentDescriptorFilePath) + try samDeploymentDescriptor.write( + to: samDeploymentDescriptorFileUrl, atomically: true, encoding: .utf8) + verboseLogging ? print("\(samDeploymentDescriptorFilePath)") : nil + + } catch let error as PluginError { + print("Error while compiling Deploy.swift") + print(error) + print("Run the deploy plugin again with --verbose argument to receive more details.") + throw PluginError.error(error) } catch { - print("Can not execute file at:") - print("\(deploymentDescriptorExecutableUrl.path)") - print("or at:") - print("\(samExecutableUrl.path)") - print("Is SAM installed ? (brew tap aws/tap && brew install aws-sam-cli)") - print("Did you add a 'Deploy' executable target into your project's Package.swift ?") - print("Did you build the release version ? (swift build -c release)") + print("Unexpected error : \(error)") + throw PluginError.error(error) } + } - - @discardableResult - private func execute( - executable: URL, - arguments: [String], - customWorkingDirectory: URL? = nil, - logLevel: ProcessLogLevel - ) throws -> String { - try self.execute( - executable: Path(executable.path), - arguments: arguments, - customWorkingDirectory: customWorkingDirectory == nil - ? nil : Path(customWorkingDirectory!.path), - logLevel: logLevel) - } - - // ************************************************************** - // Below this line, the code is copied from the archiver plugin - // ************************************************************** - @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) } + + 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 PluginError.toolNotFound(executableName) } - - 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) + + if verboseLogging { + print("-------------------------------------------------------------------------") + print("\(executableName) executable : \(executable.path)") + print("-------------------------------------------------------------------------") } - process.terminationHandler = { _ in - outputQueue.async { - outputHandler(try? pipe.fileHandleForReading.readToEnd()) - } + return executable.path + } + + private func validate(samExecutablePath: Path, + samDeploymentDescriptorFilePath: String, + verboseLogging: Bool) throws { + + print("-------------------------------------------------------------------------") + print("Validating SAM deployment descriptor") + print("-------------------------------------------------------------------------") + + do { + try execute( + executable: samExecutablePath, + arguments: ["validate", "-t", samDeploymentDescriptorFilePath], + logLevel: verboseLogging ? .debug : .silent) + + } catch let error as PluginError { + print("Error while validating the SAM template.") + print(error) + print("Run the deploy plugin again with --verbose argument to receive more details.") + throw PluginError.error(error) + } catch { + print("Unexpected error : \(error)") + throw PluginError.error(error) } + } + + private func deploy(samExecutablePath: Path, + samDeploymentDescriptorFilePath: String, + stackName: String, + verboseLogging: Bool) throws { + + //TODO: check if there is a samconfig.toml file. + // when there is no file, generate one with default data or data collected from params + + + print("-------------------------------------------------------------------------") + print("Deploying AWS Lambda function") + print("-------------------------------------------------------------------------") + do { - 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) + try execute( + executable: samExecutablePath, + arguments: ["deploy", + "-t", samDeploymentDescriptorFilePath, + "--stack-name", stackName, + "--capabilities", "CAPABILITY_IAM", + "--resolve-s3"], + logLevel: verboseLogging ? .debug : .silent) + } catch let error as PluginError { + print("Error while deploying the SAM template.") + print(error) + print("Run the deploy plugin again with --verbose argument to receive more details.") + throw PluginError.error(error) + } catch { + print("Unexpected error : \(error)") + throw PluginError.error(error) } - - return output } - // ************************************************************** - // end copied code - // ************************************************************** } private struct Configuration: CustomStringConvertible { - public let products: [Product] - public let deployExecutable: URL - public let explicitProducts: Bool public let buildConfiguration: PackageManager.BuildConfiguration public let noDeploy: Bool public let verboseLogging: Bool - + public let archiveDirectory: String + public let stackName: String + + private let context: PluginContext + public init( context: PluginContext, arguments: [String] ) throws { - - // extrcat command line arguments + + 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 productsArgument = argumentExtractor.extractOption(named: "products") let configurationArgument = argumentExtractor.extractOption(named: "configuration") - + let archiveDirectoryArgument = argumentExtractor.extractOption(named: "archive-path") + let stackNamenArgument = argumentExtractor.extractOption(named: "stackname") + // define deployment option self.noDeploy = nodeployArgument - + // define logging verbosity self.verboseLogging = verboseArgument - - // define products - self.explicitProducts = !productsArgument.isEmpty - if self.explicitProducts { - 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") - } - } - self.products = products - - } else { - self.products = context.package.products.filter { - $0 is ExecutableProduct && $0.name != "Deploy" - } - } - - // define build configuration + + // define build configuration, defaults to debug if let buildConfigurationName = configurationArgument.first { guard let buildConfiguration = PackageManager.BuildConfiguration(rawValue: buildConfigurationName) else { - throw Errors.invalidArgument( + throw PluginError.invalidArgument( "invalid build configuration named '\(buildConfigurationName)'") } self.buildConfiguration = buildConfiguration } else { - self.buildConfiguration = .release - } - - // search for deployment configuration executable - let deployProducts = context.package.products.filter { $0.name == "Deploy" } - guard deployProducts.count == 1, - deployProducts[0].targets.count == 1 - else { - throw Errors.deploymentDescriptorProductNotFound("Deploy") + self.buildConfiguration = .debug } - for t in deployProducts[0].targets { - print("\(t.name) - \(t.directory)") + + // use a default archive directory when none are given + if let archiveDirectory = archiveDirectoryArgument.first { + self.archiveDirectory = archiveDirectory + } else { + self.archiveDirectory = "\(context.package.directory.string)/.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/" } - guard deployProducts[0].targets.count == 1 else { - fatalError("There is more than one Deploy target \(deployProducts[0].targets)") + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: self.archiveDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { + throw PluginError.invalidArgument( + "invalid archive directory: \(self.archiveDirectory)\nthe directory does not exists") } -#if arch(arm64) - let arch = "arm64-apple-macosx" -#else - let arch = "x86_64-apple-macosx" -#endif - var deployExecutableURL = URL(fileURLWithPath: deployProducts[0].targets[0].directory.string) - // while there is /Sources or /Deploy => remove last path component - while deployExecutableURL.path.contains("/Sources") || - deployExecutableURL.path.contains("/Deploy") { - - deployExecutableURL = deployExecutableURL.deletingLastPathComponent() + + // infer or consume stackname + if let stackName = stackNamenArgument.first { + self.stackName = stackName + } else { + self.stackName = context.package.displayName } - self.deployExecutable = deployExecutableURL.appendingPathComponent(".build/\(arch)/\(self.buildConfiguration)/Deploy") if self.verboseLogging { print("-------------------------------------------------------------------------") @@ -276,40 +283,42 @@ private struct Configuration: CustomStringConvertible { print(self) } } - + var description: String { """ { - products: \(self.products.map(\.name)) + verbose: \(self.verboseLogging) + noDeploy: \(self.noDeploy) buildConfiguration: \(self.buildConfiguration) - deployExecutable: \(self.deployExecutable) + archiveDirectory: \(self.archiveDirectory) + stackName: \(self.stackName) + + Plugin directory: \(self.context.pluginWorkDirectory) + Project directory: \(self.context.package.directory) } """ } } -private enum Errors: Error, CustomStringConvertible { +private enum PluginError: Error, CustomStringConvertible { case invalidArgument(String) - case unsupportedPlatform(String) - case unknownProduct(String) - case productExecutableNotFound(String) - case deploymentDescriptorProductNotFound(String) case processFailed([String], Int32) - + case toolNotFound(String) + case deployswiftDoesNotExist + case error(Error) + var description: String { switch self { case .invalidArgument(let description): return description - case .unsupportedPlatform(let description): - return description - case .unknownProduct(let description): - return description - case .productExecutableNotFound(let product): - return "product executable not found '\(product)'" - case .deploymentDescriptorProductNotFound(let product): - return "your project Package.swift has no executable named '\(product)'" - case .processFailed(let arguments, let code): - return "\(arguments.joined(separator: " ")) failed with code \(code)" + case .processFailed(let command, let code): + return "\(command.joined(separator: " ")) failed with exit code \(code)" + case .toolNotFound(let tool): + return tool + case .deployswiftDoesNotExist: + return "Deploy.swift does not exist" + case .error(let rootCause): + return "Error caused by:\n\(rootCause)" } } } @@ -317,12 +326,88 @@ private enum Errors: Error, CustomStringConvertible { // ************************************************************** // Below this line, the code is copied from the archiver plugin // ************************************************************** +@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 PluginError.processFailed([executable.string] + arguments, process.terminationStatus) + } + + return output +} private enum ProcessLogLevel: Comparable { case silent case output(outputIndent: Int) case debug(outputIndent: Int) - + var naturalOrder: Int { switch self { case .silent: @@ -333,16 +418,21 @@ private enum ProcessLogLevel: Comparable { 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 } } + + +// ************************************************************** +// end copied code +// ************************************************************** diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index cb323366..4aaf9ac8 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -1,526 +1,120 @@ -// ===----------------------------------------------------------------------===// -// -// 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 Foundation -public protocol DeploymentDefinition: Encodable {} - -// 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 decsriptor. -// 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 classes -public struct SAMDeployment: DeploymentDefinition { - - let awsTemplateFormatVersion: String - let transform: String - let description: String - var resources: [String: Resource] +// +// The deployment definition +// +public struct DeploymentDefinition { public init( - templateVersion: String = "2010-09-09", - transform: String = "AWS::Serverless-2016-10-31", - description: String = "A SAM template to deploy a Swift Lambda function", - resources: [Resource] = [] - ) { - - self.awsTemplateFormatVersion = templateVersion - self.transform = transform - self.description = description - self.resources = [String: Resource]() - - for res in resources { - self.resources[res.name] = res - } - } - - enum CodingKeys: String, CodingKey { - case awsTemplateFormatVersion = "AWSTemplateFormatVersion" - case transform = "Transform" - case description = "Description" - case resources = "Resources" - } -} - -public protocol SAMResource: Encodable {} -public protocol SAMResourceProperties: Encodable {} - -public struct Resource: SAMResource, Equatable { - - let type: String - let properties: SAMResourceProperties - let name: String - - public static func none() -> [Resource] { return [] } - - public static func == (lhs: Resource, rhs: Resource) -> Bool { - lhs.type == rhs.type && lhs.name == rhs.name - } - - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" - } - - // this is to make the compiler happy : Resource now confoms to Encodable - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try? container.encode(self.type, forKey: .type) - try? container.encode(self.properties, forKey: .properties) - } -} - -//MARK: Lambda Function resource definition -/*--------------------------------------------------------------------------------------- - Lambda Function - - https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html ------------------------------------------------------------------------------------------*/ - -extension Resource { - public static func serverlessFunction(name: String, - codeUri: String, - eventSources: [EventSource] = [], - environment: EnvironmentVariable = EnvironmentVariable.none()) -> Resource { - - let properties = ServerlessFunctionProperties(codeUri: codeUri, - eventSources: eventSources, - environment: environment) - return Resource(type: "AWS::Serverless::Function", - properties: properties, - name: name) - } - -} - -public struct ServerlessFunctionProperties: SAMResourceProperties { - public enum Architectures: String, Encodable { - case x64 = "x86_64" - case arm64 = "arm64" - } - let architectures: [Architectures] - let handler: String - let runtime: String - let codeUri: String - let autoPublishAlias: String - var eventSources: [String: EventSource] - var environment: EnvironmentVariable - - public init(codeUri: String, - eventSources: [EventSource] = [], - environment: EnvironmentVariable = EnvironmentVariable.none()) { - - #if arch(arm64) //when we build on Arm, we deploy on Arm - self.architectures = [.arm64] - #else - self.architectures = [.x64] - #endif - self.handler = "Provided" - self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 - self.autoPublishAlias = "Live" - self.codeUri = codeUri - self.eventSources = [String: EventSource]() - self.environment = environment - - for es in eventSources { - self.eventSources[es.name] = es - } - } - - // custom encoding to not provide Environment variables when there is none - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.architectures, forKey: .architectures) - try container.encode(self.handler, forKey: .handler) - try container.encode(self.runtime, forKey: .runtime) - try container.encode(self.codeUri, forKey: .codeUri) - try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) - try container.encode(self.eventSources, forKey: .eventSources) - if !environment.isEmpty() { - try container.encode(self.environment, forKey: .environment) - } - } - - enum CodingKeys: String, CodingKey { - case architectures = "Architectures" - case handler = "Handler" - case runtime = "Runtime" - case codeUri = "CodeUri" - case autoPublishAlias = "AutoPublishAlias" - case eventSources = "Events" - case environment = "Environment" - } -} - -/* - Environment: - Variables: - LOG_LEVEL: debug - */ -public struct EnvironmentVariable: Encodable { - - public var variables: [String:EnvironmentVariableValue] = [:] - public init() {} - public init(_ variables: [String:String]) { - for key in variables.keys { - self.variables[key] = .string(value: variables[key] ?? "") - } - } - public static func none() -> EnvironmentVariable { return EnvironmentVariable([:]) } - 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: String, CodingKey { - case variables = "Variables" - } - - public func encode(to encoder: Encoder) throws { - 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 + // the description of the SAM template + description: String, + + // a list of AWS Lambda functions + functions: [Function], + + // a list of additional AWS resources to create + resources: [Resource]) + { + + var queueResources : [Resource] = [] + let functionResources = functions.compactMap { function in + + // compute the path for the lambda archive + var lambdaPackage = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(function.name)/\(function.name).zip" + if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { + if CommandLine.arguments.count >= optIdx + 1 { + let archiveArg = CommandLine.arguments[optIdx + 1] + lambdaPackage = "\(archiveArg)/\(function.name)/\(function.name).zip" + + // check the ZIP file exists + guard FileManager.default.fileExists(atPath: lambdaPackage) else { + fatalError("Lambda package does not exist at \(lambdaPackage)") + } + } } + + // extract sqs resources to be created, if any + queueResources += self.explicitQueueResources(function: function) + + return Resource.serverlessFunction(name: function.name, + codeUri: lambdaPackage, + eventSources: function.eventSources, + environment: function.environment) } - } - - public enum EnvironmentVariableValue { - // KEY: VALUE - case string(value: String) - // KEY: - // Ref: VALUE - case array(value: [String:String]) + let deployment = SAMDeployment(description: description, + resources: functionResources + queueResources) - // KEY: - // Fn::GetAtt: - // - VALUE1 - // - VALUE2 - case dictionary(value: [String:[String]]) - } - - private 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: Lambda Function event source - -public protocol SAMEvent : Encodable, Equatable {} -public protocol SAMEventProperties : Encodable {} - -public struct EventSource: SAMEvent { - - let type: String - let properties: SAMEventProperties? - let name: String + //TODO: add default output section to return the URL of the API Gateway - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" + dumpPackageAtExit(deployment, to: 1) // 1 = stdout } - public static func == (lhs: EventSource, rhs: EventSource) -> Bool { - lhs.type == rhs.type && lhs.name == rhs.name - } - - public static func none() -> [EventSource] { return [] } - - // this is to make the compiler happy : Resource now confoms 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: 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 ------------------------------------------------------------------------------------------*/ - -extension EventSource { - public static func httpApi(name: String = "HttpApiEvent", - method: HttpVerb? = nil, - path: String? = nil) -> EventSource { - - var properties: SAMEventProperties? = nil - if method != nil || path != nil { - properties = HttpApiProperties(method: method, path: path) - } + // 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 eventually creates the queue resource and it 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 synthetize + private func explicitQueueResources(function: Function) -> [Resource] { - return EventSource(type: "HttpApi", - properties: properties, - name: name) + return function.eventSources + // first filter on event sources of type SQS where the `queue` property is defined (not nil) + .filter{ lambdaEventSource in + lambdaEventSource.type == .sqs && (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil } + // next extract the resource part of the sqsEventSource + .compactMap { + sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue } } } -struct HttpApiProperties: SAMEventProperties, Equatable { - init(method: HttpVerb? = nil, path: String? = nil) { - self.method = method - self.path = path - } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: HttpApiKeys.self) - if let method = self.method { - try container.encode(method, forKey: .method) - } - if let path = self.path { - try container.encode(path, forKey: .path) - } - } - let method: HttpVerb? - let path: String? - - enum HttpApiKeys: String, CodingKey { - case method = "Method" - case path = "Path" - } -} - -public enum HttpVerb: String, Encodable { - case GET - case POST -} +// Intermediate structure to generate SAM Resources of type AWS::Serverless::Function +// this struct allows function developers to not provide the CodeUri property in Deploy.swift +// CodeUri is added when the SAM template is generated +// it is also a place to perform additional sanity checks -//MARK: SQS event definition -/*--------------------------------------------------------------------------------------- - SQS Event - - https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html ------------------------------------------------------------------------------------------*/ - -extension EventSource { - private static func sqs(name: String = "SQSEvent", - properties: SQSEventProperties) -> EventSource { - - return EventSource(type: "SQS", - properties: properties, - name: name) - } - public static func sqs(name: String = "SQSEvent", - queue queueRef: String) -> EventSource { - - let properties = SQSEventProperties(byRef: queueRef) - return EventSource.sqs(name: name, - properties: properties) - } - - public static func sqs(name: String = "SQSEvent", - queue: Resource) -> EventSource { - - let properties = SQSEventProperties(queue) - return EventSource.sqs(name: name, - properties: properties) - } -} - -/** - 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: SAMEventProperties, Equatable { - - public var queueByArn: String? = nil - public var queue: Resource? = nil - - init(byRef ref: String) { - - // 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)?.arn { - self.queueByArn = arn - } else { - let logicalName = Resource.logicalName(resourceType: "Queue", - resourceName: ref) - self.queue = Resource.queue(name: logicalName, - properties: SQSResourceProperties(queueName: ref)) +//TODO: should I move this to the Resource() struct ? Then I need a way to add CodeUri at a later stage +public struct Function { + let name: String + let eventSources: [EventSource] + let environment: EnvironmentVariable + public static func function(name : String, + eventSources: [EventSource], + environment: EnvironmentVariable = .none) -> Function { + + //TODO: report an error when multiple event sources of the same type are given + //but print() is not sent to stdout when invoked from the plugin 🤷‍♂️ + + // guardrail to avoid misformed SAM template + if eventSources.filter({ source in source.type == .sqs }).count > 1 || + eventSources.filter({ source in source.type == .httpApi }).count > 1 { + fatalError("WARNING - Function \(name) can only have one event source of each type") } - } - init(_ queue: Resource) { self.queue = queue } - - enum CodingKeys: String, CodingKey { - case queue = "Queue" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - // 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 - if let queueByArn { - try container.encode(queueByArn, forKey: .queue) - } else if let queue { - var getAttIntrinsicFunction: [String:[String]] = [:] - getAttIntrinsicFunction["Fn::GetAtt"] = [ queue.name, "Arn"] - try container.encode(getAttIntrinsicFunction, forKey: .queue) - } - } -} - -//MARK: SQS queue resource definition -/*--------------------------------------------------------------------------------------- - SQS Queue Resource - - Documentation - https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html ------------------------------------------------------------------------------------------*/ -extension Resource { - public static func queue(name: String, - properties: SQSResourceProperties) -> Resource { - - return Resource(type: "AWS::SQS::Queue", - properties: properties, - name: name) - } - - public static func queue(logicalName: String, - physicalName: String ) -> Resource { - - let sqsProperties = SQSResourceProperties(queueName: physicalName) - return queue(name: logicalName, properties: sqsProperties) + return self.init(name: name, + eventSources: eventSources, + environment: environment) } } -public struct SQSResourceProperties: SAMResourceProperties { - public let queueName: String - enum CodingKeys: String, CodingKey { - case queueName = "QueueName" - } +// inspired by +// https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/PackageDescription.swift#L479 +private func manifestToJSON(_ deploymentDescriptor : DeploymentDescriptor) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let jsonData = try! encoder.encode(deploymentDescriptor) + return String(data: jsonData, encoding: .utf8)! } - -//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 ------------------------------------------------------------------------------------------*/ - -extension Resource { - public static func table(name: String, - properties: SimpleTableProperties) -> Resource { - - return Resource(type: "AWS::Serverless::SimpleTable", - properties: properties, - name: name) +private var dumpInfo: (deploymentDescriptor: DeploymentDescriptor, fileDesc: Int32)? +private func dumpPackageAtExit(_ deploymentDescriptor: DeploymentDescriptor, to fileDesc: Int32) { + func dump() { + guard let dumpInfo = dumpInfo else { return } + guard let fd = fdopen(dumpInfo.fileDesc, "w") else { return } + fputs(manifestToJSON(dumpInfo.deploymentDescriptor), fd) + fclose(fd) } - public static func table(logicalName: String, - physicalName: String, - primaryKeyName: String, - primaryKeyType: String) -> Resource { - let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) - let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) - return table(name: logicalName, properties: properties) - }} + dumpInfo = (deploymentDescriptor, fileDesc) + atexit(dump) +} -public struct SimpleTableProperties: SAMResourceProperties { - let primaryKey: PrimaryKey - let tableName: String - let provisionedThroughput: ProvisionedThroughput? = nil - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try? container.encode(tableName, forKey: .tableName) - try? container.encode(primaryKey, forKey: .primaryKey) - if let provisionedThroughput = self.provisionedThroughput { - try container.encode(provisionedThroughput, forKey: .provisionedThroughput) - } - } - enum CodingKeys: String, CodingKey { - case primaryKey = "PrimaryKey" - case tableName = "TableName" - case provisionedThroughput = "ProvisionedThroughput" - } - struct PrimaryKey: Codable { - let name: String - let type: String - enum CodingKeys: String, CodingKey { - case name = "Name" - case type = "Type" - } - } - struct ProvisionedThroughput: Codable { - let readCapacityUnits: Int - let writeCapacityUnits: Int - enum CodingKeys: String, CodingKey { - case readCapacityUnits = "ReadCapacityUnits" - case writeCapacityUnits = "WriteCapacityUnits" - } - } -} - - -//MARK: Utils - -struct Arn { - public let arn: String - init?(_ arn: String) { - // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# - if arn.range(of: arnRegex, options: .regularExpression) != nil { - self.arn = arn - } else { - return nil - } - } -} - -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 - } -} - -public enum DeploymentEncodingError: Error { - case yamlError(causedBy: Error) - case jsonError(causedBy: Error) - case stringError(causedBy: Data) -} diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 471fa40f..2d57fb20 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -13,120 +13,529 @@ // ===----------------------------------------------------------------------===// import Foundation -import Yams -// this is the developer-visible part of the deployment decsriptor. -// the rest is generated automatically. -public protocol DeploymentDescriptor { +public protocol DeploymentDescriptor: Encodable {} + +// 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 decsriptor. +// 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 classes +public struct SAMDeployment: DeploymentDescriptor { - // returns the event sources for a given Lambda function - func eventSources(_ lambdaName: String) -> [EventSource] + let awsTemplateFormatVersion: String + let transform: String + let description: String + var resources: [String: Resource] - // returns environment variables to associate with the given Lambda function - func environmentVariables(_ lambdaName: String) -> EnvironmentVariable + public init( + templateVersion: String = "2010-09-09", + transform: String = "AWS::Serverless-2016-10-31", + description: String = "A SAM template to deploy a Swift Lambda function", + resources: [Resource] = [] + ) { + + self.awsTemplateFormatVersion = templateVersion + self.transform = transform + self.description = description + self.resources = [String: Resource]() + + for res in resources { + self.resources[res.name] = res + } + } + + enum CodingKeys: String, CodingKey { + case awsTemplateFormatVersion = "AWSTemplateFormatVersion" + case transform = "Transform" + case description = "Description" + case resources = "Resources" + } +} - // returns additional resources to create in the cloud (ex : a dynamoDB table) - func addResource() -> [Resource] +public protocol SAMResource: Encodable {} +public protocol SAMResourceProperties: Encodable {} + +public struct Resource: SAMResource, Equatable { + + let type: String + let properties: SAMResourceProperties + let name: String + + public static func == (lhs: Resource, rhs: Resource) -> Bool { + lhs.type == rhs.type && lhs.name == rhs.name + } + + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } + + // this is to make the compiler happy : Resource now confoms to Encodable + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encode(self.type, forKey: .type) + try? container.encode(self.properties, forKey: .properties) + } } -// Generates a deployment descriptor modelized by DeploymentDefinition -extension DeploymentDescriptor { +//MARK: Lambda Function resource definition +/*--------------------------------------------------------------------------------------- + Lambda Function + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html + -----------------------------------------------------------------------------------------*/ + +extension Resource { + public static func serverlessFunction(name: String, + codeUri: String, + eventSources: [EventSource] = [], + environment: EnvironmentVariable = .none) -> Resource { + + let properties = ServerlessFunctionProperties(codeUri: codeUri, + eventSources: eventSources, + environment: environment) + return Resource(type: "AWS::Serverless::Function", + properties: properties, + name: name) + } +} + +public struct ServerlessFunctionProperties: SAMResourceProperties { + public enum Architectures: String, Encodable { + case x64 = "x86_64" + case arm64 = "arm64" + } + let architectures: [Architectures] + let handler: String + let runtime: String + let codeUri: String + let autoPublishAlias: String + var eventSources: [String: EventSource] + var environment: EnvironmentVariable - // create the SAM deployment descriptor data structure - // this method use multiple data sources : - // - it calls protocol functions implemented by the lambda function developer to get function-specific - // details (event source, environment variables) - // - it uses command line arguments (list of lambda names) - // - it uses some default values (like the ZIP file produced by archive command) - public func deploymentDefinition() -> DeploymentDefinition { + public init(codeUri: String, + eventSources: [EventSource] = [], + environment: EnvironmentVariable = .none) { - // Create function resources for each Lambda function - var resources = self.lambdaNames().map { name in - return createServerlessFunctionResource(name) // returns [Resource] - }.flatMap{ $0 } // convert [[Resource]] to [Resource] - - // add developer-provided resources - resources.append(contentsOf: self.addResource()) +#if arch(arm64) //when we build on Arm, we deploy on Arm + self.architectures = [.arm64] +#else + self.architectures = [.x64] +#endif + self.handler = "Provided" + self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 + self.autoPublishAlias = "Live" + self.codeUri = codeUri + self.eventSources = [String: EventSource]() + self.environment = environment + + for es in eventSources { + self.eventSources[es.name] = es + } + } + + // custom encoding to not provide Environment variables when there is none + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.architectures, forKey: .architectures) + try container.encode(self.handler, forKey: .handler) + try container.encode(self.runtime, forKey: .runtime) + try container.encode(self.codeUri, forKey: .codeUri) + try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) + try container.encode(self.eventSources, forKey: .eventSources) + if !environment.isEmpty() { + try container.encode(self.environment, forKey: .environment) + } + } + + enum CodingKeys: String, CodingKey { + case architectures = "Architectures" + case handler = "Handler" + case runtime = "Runtime" + case codeUri = "CodeUri" + case autoPublishAlias = "AutoPublishAlias" + case eventSources = "Events" + case environment = "Environment" + } +} - // create the SAM deployment descriptor - return SAMDeployment(resources: resources) +/* + Environment: + Variables: + LOG_LEVEL: debug + */ +public struct EnvironmentVariable: Encodable { + + public var variables: [String:EnvironmentVariableValue] = [:] + public init() {} + public init(_ variables: [String:String]) { + for key in variables.keys { + self.variables[key] = .string(value: variables[key] ?? "") + } + } + public static var none : EnvironmentVariable { return EnvironmentVariable([:]) } + + public static func variable(_ name: String, _ value: String) -> EnvironmentVariable { return EnvironmentVariable([name: value]) } + public static func variable(_ variables: [String:String]) -> EnvironmentVariable { return EnvironmentVariable(variables) } + public static func variable(_ variables: [[String:String]]) -> EnvironmentVariable { + + var mergedDictKeepCurrent : [String:String] = [:] + variables.forEach { dict in + // inspired by https://stackoverflow.com/a/43615143/663360 + mergedDictKeepCurrent = mergedDictKeepCurrent.merging(dict) { (current, _) in current } + } + + return EnvironmentVariable(mergedDictKeepCurrent) + } + public func isEmpty() -> Bool { return variables.count == 0 } - // returns environment variables to associate with the given Lambda function - // This is a default implementation to avoid forcing the lambda developer to implement it when not needed - public func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { - return EnvironmentVariable.none() + 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]) } - // returns additional resources to create in the cloud (ex : a dynamoDB table) - // This is a default implementation to avoid forcing the lambda developer to implement it when not needed - public func addResource() -> [Resource] { - return Resource.none() + enum CodingKeys: String, CodingKey { + case variables = "Variables" } - // entry point and main function. Lambda developer must call this function. - public func run() { - do { - let sam = try toYaml() - print(sam) - } catch { - print(error) + public func encode(to encoder: Encoder) throws { + 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 + } } } - // The lambda function names that are passed as command line argument - // it is used to infer the directory and the name of the ZIP files - private func lambdaNames() -> [String] { - if CommandLine.arguments.count < 2 { - fatalError( - "You must pass the AWS Lambda function names as list of arguments\n\nFor example: ./deploy LambdaOne LambdaTwo" - ) + public enum EnvironmentVariableValue { + // KEY: VALUE + case string(value: String) + + // KEY: + // Ref: VALUE + case array(value: [String:String]) + + // KEY: + // Fn::GetAtt: + // - VALUE1 + // - VALUE2 + case dictionary(value: [String:[String]]) + } + + private 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: Lambda Function event source + +public protocol SAMEvent : Encodable, Equatable {} +public protocol SAMEventProperties : Encodable {} + +public enum EventSourceType: String, Encodable { + case httpApi = "HttpApi" + case sqs = "SQS" +} +public struct EventSource: SAMEvent { + + let type: EventSourceType + let properties: SAMEventProperties? + let name: String + + enum CodingKeys: String, CodingKey { + case type = "Type" + case properties = "Properties" + } + + public static func == (lhs: EventSource, rhs: EventSource) -> Bool { + lhs.type == rhs.type && lhs.name == rhs.name + } + + public static func none() -> [EventSource] { return [] } + + // this is to make the compiler happy : Resource now confoms 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: 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 + -----------------------------------------------------------------------------------------*/ + +extension EventSource { + public static func httpApi(name: String = "HttpApiEvent", + method: HttpVerb? = nil, + path: String? = nil) -> EventSource { + + var properties: SAMEventProperties? = nil + if method != nil || path != nil { + properties = HttpApiProperties(method: method, path: path) + } + + return EventSource(type: .httpApi, + properties: properties, + name: name) + } +} + +struct HttpApiProperties: SAMEventProperties, Equatable { + init(method: HttpVerb? = nil, path: String? = nil) { + self.method = method + self.path = path + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: HttpApiKeys.self) + if let method = self.method { + try container.encode(method, forKey: .method) + } + if let path = self.path { + try container.encode(path, forKey: .path) + } + } + let method: HttpVerb? + let path: String? + + enum HttpApiKeys: String, CodingKey { + case method = "Method" + case path = "Path" + } +} + +public enum HttpVerb: String, Encodable { + case GET + case POST +} + +//MARK: SQS event definition +/*--------------------------------------------------------------------------------------- + SQS Event + + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html + -----------------------------------------------------------------------------------------*/ + +extension EventSource { + private static func sqs(name: String = "SQSEvent", + properties: SQSEventProperties) -> EventSource { + + return EventSource(type: .sqs, + properties: properties, + name: name) + } + public static func sqs(name: String = "SQSEvent", + queue queueRef: String) -> EventSource { + + let properties = SQSEventProperties(byRef: queueRef) + return EventSource.sqs(name: name, + properties: properties) + } + + public static func sqs(name: String = "SQSEvent", + queue: Resource) -> EventSource { + + let properties = SQSEventProperties(queue) + return EventSource.sqs(name: name, + properties: properties) + } +} + +/** + 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: SAMEventProperties, Equatable { + + public var queueByArn: String? = nil + public var queue: Resource? = nil + + init(byRef ref: String) { + + // 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)?.arn { + self.queueByArn = arn } else { - return [String](CommandLine.arguments[1...]) + let logicalName = Resource.logicalName(resourceType: "Queue", + resourceName: ref) + self.queue = Resource.queue(name: logicalName, + properties: SQSResourceProperties(queueName: ref)) } + } + init(_ queue: Resource) { self.queue = queue } - // generate the YAML version of the deployment descriptor - // keep the method public for testability - public func toYaml() throws -> String { - let deploy = deploymentDefinition() + enum CodingKeys: String, CodingKey { + case queue = "Queue" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) - do { - let yaml = try YAMLEncoder().encode(deploy) - return yaml - } catch { - throw DeploymentEncodingError.yamlError(causedBy: error) + // 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 + if let queueByArn { + try container.encode(queueByArn, forKey: .queue) + } else if let queue { + var getAttIntrinsicFunction: [String:[String]] = [:] + getAttIntrinsicFunction["Fn::GetAtt"] = [ queue.name, "Arn"] + try container.encode(getAttIntrinsicFunction, forKey: .queue) } } +} - private func createServerlessFunctionResource(_ name: String) -> [Resource] { +//MARK: SQS queue resource definition +/*--------------------------------------------------------------------------------------- + SQS Queue Resource + + Documentation + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html + -----------------------------------------------------------------------------------------*/ +extension Resource { + public static func queue(name: String, + properties: SQSResourceProperties) -> Resource { - // the default output dir for archive plugin - // FIXME: add support for --output-path option on packager - let package = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(name)/\(name).zip" - - // add event sources provided by Lambda developer - let lambdaEventSources = eventSources(name) + return Resource(type: "AWS::SQS::Queue", + properties: properties, + name: name) + } + + public static func queue(logicalName: String, + physicalName: String ) -> Resource { - // 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 name or a queue resource, - // the event source eventually creates the queue resource and references the resource. - // Now, we need to collect all queue resources created by SQS event sources or passed by Lambda function develper - // to add them to the list of resources to synthetize - var resources : [Resource] = lambdaEventSources.filter{ lambdaEventSource in - lambdaEventSource.type == "SQS" && - (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil - }.compactMap { sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue } + let sqsProperties = SQSResourceProperties(queueName: physicalName) + return queue(name: logicalName, properties: sqsProperties) + } +} - // finally, let's build the function definition - let serverlessFunction = Resource.serverlessFunction(name: name, - codeUri: package, - eventSources: lambdaEventSources, - environment: environmentVariables(name)) +public struct SQSResourceProperties: SAMResourceProperties { + public let queueName: String + enum CodingKeys: String, CodingKey { + case queueName = "QueueName" + } +} + +//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 + -----------------------------------------------------------------------------------------*/ + +extension Resource { + public static func table(name: String, + properties: SimpleTableProperties) -> Resource { - // put all resources together - resources.append(serverlessFunction) - return resources + return Resource(type: "AWS::Serverless::SimpleTable", + properties: properties, + name: name) + } + public static func table(logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String) -> Resource { + let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) + let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) + return table(name: logicalName, properties: properties) + }} + +public struct SimpleTableProperties: SAMResourceProperties { + let primaryKey: PrimaryKey + let tableName: String + let provisionedThroughput: ProvisionedThroughput? = nil + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encode(tableName, forKey: .tableName) + try? container.encode(primaryKey, forKey: .primaryKey) + if let provisionedThroughput = self.provisionedThroughput { + try container.encode(provisionedThroughput, forKey: .provisionedThroughput) + } + } + enum CodingKeys: String, CodingKey { + case primaryKey = "PrimaryKey" + case tableName = "TableName" + case provisionedThroughput = "ProvisionedThroughput" } + struct PrimaryKey: Codable { + let name: String + let type: String + enum CodingKeys: String, CodingKey { + case name = "Name" + case type = "Type" + } + } + struct ProvisionedThroughput: Codable { + let readCapacityUnits: Int + let writeCapacityUnits: Int + enum CodingKeys: String, CodingKey { + case readCapacityUnits = "ReadCapacityUnits" + case writeCapacityUnits = "WriteCapacityUnits" + } + } +} + + +//MARK: Utils + +struct Arn { + public let arn: String + init?(_ arn: String) { + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + if arn.range(of: arnRegex, options: .regularExpression) != nil { + self.arn = arn + } else { + return nil + } + } +} + +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 + } +} + +public enum DeploymentEncodingError: Error { + case yamlError(causedBy: Error) + case jsonError(causedBy: Error) + case stringError(causedBy: Data) } From 09385fac17cd22826c4b8f49b5525e34efd2c312 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 30 Jan 2023 22:50:37 +0100 Subject: [PATCH 18/79] remove old Deployment swift code --- Examples/SAM/Deploy/Deploy.swift | 78 -------------------------------- 1 file changed, 78 deletions(-) delete mode 100755 Examples/SAM/Deploy/Deploy.swift diff --git a/Examples/SAM/Deploy/Deploy.swift b/Examples/SAM/Deploy/Deploy.swift deleted file mode 100755 index 658caef8..00000000 --- a/Examples/SAM/Deploy/Deploy.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AWSLambdaDeploymentDescriptor -import Foundation - -@main -public struct HttpApiLambdaDeployment: DeploymentDescriptor { - - // you have to include a main() method to generate the deployment descriptor - static func main() throws { - HttpApiLambdaDeployment().run() - } - - // optional, example of a shared resources between multiple Lambda functions - var queue : Resource - var table : Resource - init() { - self.queue = Resource.queue(logicalName: "SharedQueue", - physicalName: "swift-lambda-shared-queue") - self.table = Resource.table(logicalName: "SwiftLambdaTable", - physicalName: "swift-lambda-table", - primaryKeyName: "id", - primaryKeyType: "String") - } - - // optional, define the event sources for each Lambda function - public func eventSources(_ lambdaName: String) -> [EventSource] { - - if lambdaName == "HttpApiLambda" { - // example of a Lambda function exposed through a REST API - return [ - - // this defines a catch all API (for all HTTP verbs and paths) - .httpApi() - - // this defines a REST API for HTTP verb GET and path / test - // .httpApi(method: .GET, path: "/test"), - ] - - } else if lambdaName == "SQSLambda" { - - // example of a Lambda function triggered when a message arrive on a queue - - // this will create a new queue resource - // return [.sqs(queue: "swift-lambda-test")] - - // this will reference an existing queue - // return [.sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test")] - - // this will reference a queue resource created in this deployment descriptor - return [.sqs(queue: self.queue)] - - } else { - fatalError("Unknown Lambda name : \(lambdaName)") - } - } - - // optional, define the environment variables for each Lambda function - public func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { - - // an environment variable for all functions - var envVariables = EnvironmentVariable([ "LOG_LEVEL": "debug" ]) - - // variables specific for one Lambda function - if (lambdaName == "HttpApiLambda") { - // pass a reference to the shared queue and the DynamoDB table - envVariables.append("QUEUE_URL", self.queue) - envVariables.append("DYNAMO_TABLE", self.table) - } - - return envVariables - } - - // optional, additional resources to create on top of the Lambda functions and their direct dependencies - // in this example, I create a DynamoDB Table - public func addResource() -> [Resource] { - return [self.table] - } - -} From 607e877843785adb1398e46fffa6384700e16526 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 30 Jan 2023 23:04:49 +0100 Subject: [PATCH 19/79] add the PR README to the plugin source directory --- Plugins/AWSLambdaDeployer/README.md | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 Plugins/AWSLambdaDeployer/README.md diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md new file mode 100644 index 00000000..f47a3087 --- /dev/null +++ b/Plugins/AWSLambdaDeployer/README.md @@ -0,0 +1,145 @@ +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 solves this problem. 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 descriptor 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 support a subset of HTTP API Gateway v2 and SQS queues. + +The Lambda function developer describes the API gateway or SQS queue using the Swift programming language by writing a `Deploy.swift` file (similar to `Package.swift` used by SPM). The plugin transform the `Deploy.swift` data structure into a SAM template. It then calls the SAM CLI to validate and to deploy the template. + +### Why create a dependency on 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 ... I am curious to get community feedback about this choice. + +### Modifications: + +I added two targets to `Package.swift` : + +- `AWSLambdaDeployer` is the plugin itself. I followed the same structure and code as the `archive` plugin (similar configuration and shell code). + +- `AWSLambdaDeploymentDescriptor` is a shared library that contains the data structures definition to describe and to generate a JSON 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 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 contains minimum code to describe the required HTTP API and SQS code and to allow `AWSLambdaDeploymentDescriptor` to generate the SAM deployment descriptor. + +### 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 a new `Deploy.swift` file at the top of my project. Here is a simple deployment file. A more complex one is provided in the commit. + +```swift +import AWSLambdaDeploymentDescriptor + +let _ = DeploymentDefinition( + + functions: [ + .function( + name: "HttpApiLambda", + eventSources: [ + .httpApi(method: .GET, path: "/test"), + ], + environment: .variable(["LOG_LEVEL":"debug"]) //optional + ) + ] +) +``` + +3. I invoke the archive plugin and the deploy plugin from the command line. + +```bash + +swift build + +# 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 deploy the template. + +4. (optionally) Swift lambda function developer may also use SAM to test the code locally. + +```bash +sam local invoke -t sam.yaml -e test/apiv2.json HttpApiLambda +Invoking Provided (provided.al2) +Decompressing /Users/stormacq/Documents/amazon/code/lambda/swift/swift-aws-lambda-runtime/Examples/SAM/.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/HttpApiLambda/HttpApiLambda.zip +Skip pulling image and use local one: public.ecr.aws/sam/emulation-provided.al2:rapid-1.67.0-arm64. + +Mounting /private/var/folders/14/nwpsn4b504gfp02_mrbyd2jr0000gr/T/tmpc6ajvoxv as /var/task:ro,delegated inside runtime container +START RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 Version: $LATEST +2022-12-23T14:28:34+0000 info Lambda : [AWSLambdaRuntimeCore] lambda runtime starting with LambdaConfiguration + General(logLevel: debug)) + Lifecycle(id: 157597332736, maxTimes: 0, stopSignal: TERM) + RuntimeEngine(ip: 127.0.0.1, port: 9001, requestTimeout: nil +2022-12-23T14:28:34+0000 debug Lambda : lifecycleId=157597332736 [AWSLambdaRuntimeCore] initializing lambda +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] lambda invocation sequence starting +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] requesting work from lambda runtime engine using /2018-06-01/runtime/invocation/next +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] sending invocation to lambda handler +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [HttpApiLambda] HTTP API Message received +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] reporting results to lambda runtime engine using /2018-06-01/runtime/invocation/23cb7237-5c46-420a-b311-45ae9d4d19b7/response +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] lambda invocation sequence completed successfully +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=1 [AWSLambdaRuntimeCore] lambda invocation sequence starting +2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=1 [AWSLambdaRuntimeCore] requesting work from lambda runtime engine using /2018-06-01/runtime/invocation/next +END RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 +REPORT RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 Init Duration: 0.44 ms Duration: 115.33 ms Billed Duration: 116 ms Memory Size: 128 MB Max Memory Used: 128 MB +{"headers":{"content-type":"application\/json"},"body":"{\"isBase64Encoded\":false,\"headers\":{\"x-forwarded-for\":\"90.103.90.59\",\"sec-fetch-site\":\"none\",\"x-amzn-trace-id\":\"Root=1-63a29de7-371407804cbdf89323be4902\",\"content-length\":\"0\",\"host\":\"x6v980zzkh.execute-api.eu-central-1.amazonaws.com\",\"x-forwarded-port\":\"443\",\"accept\":\"text\\\/html,application\\\/xhtml+xml,application\\\/xml;q=0.9,image\\\/avif,image\\\/webp,*\\\/*;q=0.8\",\"sec-fetch-user\":\"?1\",\"user-agent\":\"Mozilla\\\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\\\/20100101 Firefox\\\/108.0\",\"accept-language\":\"en-US,en;q=0.8,fr-FR;q=0.5,fr;q=0.3\",\"sec-fetch-dest\":\"document\",\"dnt\":\"1\",\"sec-fetch-mode\":\"navigate\",\"x-forwarded-proto\":\"https\",\"accept-encoding\":\"gzip, deflate, br\",\"upgrade-insecure-requests\":\"1\"},\"version\":\"2.0\",\"queryStringParameters\":{\"arg1\":\"value1\",\"arg2\":\"value2\"},\"routeKey\":\"$default\",\"requestContext\":{\"domainPrefix\":\"x6v980zzkh\",\"stage\":\"$default\",\"timeEpoch\":1671601639995,\"apiId\":\"x6v980zzkh\",\"http\":{\"protocol\":\"HTTP\\\/1.1\",\"sourceIp\":\"90.103.90.59\",\"method\":\"GET\",\"userAgent\":\"Mozilla\\\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\\\/20100101 Firefox\\\/108.0\",\"path\":\"\\\/test\"},\"time\":\"21\\\/Dec\\\/2022:05:47:19 +0000\",\"domainName\":\"x6v980zzkh.execute-api.eu-central-1.amazonaws.com\",\"requestId\":\"de2cRil5liAEM5Q=\",\"accountId\":\"486652066693\"},\"rawQueryString\":\"arg1=value1&arg2=value2\",\"rawPath\":\"\\\/test\"}","statusCode":202}% +``` + +### What is missing ? + +If this proposal is accepted, 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) + +Just like for the `archive` plugin, it would be great to have a more granular permission mechanism allowing to escape the plugin sandbox for selected network calls. + +Happy to read your feedback and suggestions. Let's make the deployment of Swift Lambda functions easier for Swift developers. From fa6eddbb62846ee3db242f513f788db7401bb692 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 14:48:23 +0100 Subject: [PATCH 20/79] add linter to SAM validate --- Plugins/AWSLambdaDeployer/Plugin.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 65f9b021..06471db7 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -166,7 +166,9 @@ struct AWSLambdaPackager: CommandPlugin { do { try execute( executable: samExecutablePath, - arguments: ["validate", "-t", samDeploymentDescriptorFilePath], + arguments: ["validate", + "-t", samDeploymentDescriptorFilePath, + "--lint"], logLevel: verboseLogging ? .debug : .silent) } catch let error as PluginError { From 2d07b14d391c1c22dcce10f7de4dd6f30e74b81b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 14:49:58 +0100 Subject: [PATCH 21/79] refactor unit test --- .../DeploymentDefinition.swift | 67 ++- .../DeploymentDescriptor.swift | 10 +- .../DeploymentDescriptorTests.swift | 431 ++++++------------ .../MockedDeploymentDescriptor.swift | 54 ++- 4 files changed, 214 insertions(+), 348 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 4aaf9ac8..88a65d70 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -5,7 +5,9 @@ import Foundation // public struct DeploymentDefinition { - public init( + private var deployment : DeploymentDescriptor? = nil // optional is required to allow initialization after the capturing closure (compactMap) + + public init ( // the description of the SAM template description: String, @@ -16,25 +18,35 @@ public struct DeploymentDefinition { resources: [Resource]) { - var queueResources : [Resource] = [] + var additionalresources = resources let functionResources = functions.compactMap { function in // compute the path for the lambda archive - var lambdaPackage = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(function.name)/\(function.name).zip" + // propose a default path unless the --archive-path argument was used + var lambdaPackage : String? = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(function.name)/\(function.name).zip" if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { if CommandLine.arguments.count >= optIdx + 1 { let archiveArg = CommandLine.arguments[optIdx + 1] lambdaPackage = "\(archiveArg)/\(function.name)/\(function.name).zip" - - // check the ZIP file exists - guard FileManager.default.fileExists(atPath: lambdaPackage) else { - fatalError("Lambda package does not exist at \(lambdaPackage)") - } } } - + + // check the ZIP file exists + if !FileManager.default.fileExists(atPath: lambdaPackage!) { + // I choose to report an error in the generated JSON. + // happy to listen other ideas. + // I think they are 5 options here + // 1. fatalError() -> does not allow for unit testing + // 2. throw Error -> requires Lambda function developer to manage the error + // 3. return an empty deployement descriptor {} => would require more changes in DeploymentDescriptor + // 4. return an error message to be reported in CodeUri property => but `sam validate` does not catch it. + // 5. return nil and CodeUri will be ignored in the JSON => but `sam validate` does not catch it. + // TODO: can we add code in `Plugin.swift` to force it to fail when such error is detected + lambdaPackage = "### ERROR package does not exist: \(lambdaPackage!) ###" + } + // extract sqs resources to be created, if any - queueResources += self.explicitQueueResources(function: function) + additionalresources += self.explicitQueueResources(function: function) return Resource.serverlessFunction(name: function.name, codeUri: lambdaPackage, @@ -42,12 +54,12 @@ public struct DeploymentDefinition { environment: function.environment) } - let deployment = SAMDeployment(description: description, - resources: functionResources + queueResources) + self.deployment = SAMDeployment(description: description, + resources: functionResources + additionalresources) //TODO: add default output section to return the URL of the API Gateway - dumpPackageAtExit(deployment, to: 1) // 1 = stdout + dumpPackageAtExit(self, to: 1) // 1 = stdout } // When SQS event source is specified, the Lambda function developer @@ -67,6 +79,16 @@ public struct DeploymentDefinition { .compactMap { sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue } } + + public func toJSON(pretty: Bool = true) -> String { + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = .prettyPrinted + } + let jsonData = try! encoder.encode(self.deployment!) + return String(data: jsonData, encoding: .utf8)! + } + } // Intermediate structure to generate SAM Resources of type AWS::Serverless::Function @@ -100,21 +122,18 @@ public struct Function { // inspired by // https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/PackageDescription.swift#L479 -private func manifestToJSON(_ deploymentDescriptor : DeploymentDescriptor) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let jsonData = try! encoder.encode(deploymentDescriptor) - return String(data: jsonData, encoding: .utf8)! +// left public for testing +private func manifestToJSON(_ deploymentDefinition : DeploymentDefinition) -> String { + return deploymentDefinition.toJSON() } -private var dumpInfo: (deploymentDescriptor: DeploymentDescriptor, fileDesc: Int32)? -private func dumpPackageAtExit(_ deploymentDescriptor: DeploymentDescriptor, to fileDesc: Int32) { +private var dumpInfo: (deploymentDefinition: DeploymentDefinition, fileDesc: Int32)? +private func dumpPackageAtExit(_ deploymentDefinition: DeploymentDefinition, to fileDesc: Int32) { func dump() { guard let dumpInfo = dumpInfo else { return } guard let fd = fdopen(dumpInfo.fileDesc, "w") else { return } - fputs(manifestToJSON(dumpInfo.deploymentDescriptor), fd) + fputs(manifestToJSON(dumpInfo.deploymentDefinition), fd) fclose(fd) } - dumpInfo = (deploymentDescriptor, fileDesc) + dumpInfo = (deploymentDefinition, fileDesc) atexit(dump) -} - +} diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 2d57fb20..7c596c2e 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -89,7 +89,7 @@ public struct Resource: SAMResource, Equatable { extension Resource { public static func serverlessFunction(name: String, - codeUri: String, + codeUri: String?, eventSources: [EventSource] = [], environment: EnvironmentVariable = .none) -> Resource { @@ -110,12 +110,12 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { let architectures: [Architectures] let handler: String let runtime: String - let codeUri: String + let codeUri: String? let autoPublishAlias: String var eventSources: [String: EventSource] var environment: EnvironmentVariable - public init(codeUri: String, + public init(codeUri: String?, eventSources: [EventSource] = [], environment: EnvironmentVariable = .none) { @@ -142,7 +142,9 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { try container.encode(self.architectures, forKey: .architectures) try container.encode(self.handler, forKey: .handler) try container.encode(self.runtime, forKey: .runtime) - try container.encode(self.codeUri, forKey: .codeUri) + if let codeUri = self.codeUri { + try container.encode(codeUri, forKey: .codeUri) + } try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) try container.encode(self.eventSources, forKey: .eventSources) if !environment.isEmpty() { diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 8be54824..ee0b1401 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -28,31 +28,28 @@ final class DeploymentDescriptorTest: XCTestCase { CommandLine.arguments = originalCommandLineArgs } + private func generateAndTestDeploymentDecsriptor(deployment: MockDeploymentDescriptor, expected: String) -> Bool { + // when + let samJSON = deployment.deploymentDescriptor.toJSON(pretty: false) + print(samJSON) + // then + return samJSON.contains(expected) + } + func testSAMHeader() { // given let expected = """ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: A SAM template to deploy a Swift Lambda function +{"Description":"A SAM template to deploy a Swift Lambda function","AWSTemplateFormatVersion":"2010-09-09","Resources":{},"Transform":"AWS::Serverless-2016-10-31"} """ - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [] } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + let testDeployment = MockDeploymentDescriptor(withFunction: false) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } func testLambdaFunctionResource() { - + // given #if arch(arm64) let architecture = "arm64" @@ -60,347 +57,197 @@ Description: A SAM template to deploy a Swift Lambda function let architecture = "x86_64" #endif let expected = """ -Resources: - TestLambda: - Type: AWS::Serverless::Function - Properties: - Architectures: - - \(architecture) - Handler: Provided - Runtime: provided.al2 - CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/TestLambda/TestLambda.zip - AutoPublishAlias: Live - Events: {} +function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(architecture)"]}}} """ - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [] } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + let testDeployment = MockDeploymentDescriptor(withFunction: true) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - + func testSimpleTableResource() { - + // given let expected = """ -LogicalTestTable: - Type: AWS::Serverless::SimpleTable - Properties: - TableName: TestTable - PrimaryKey: - Name: pk - Type: String +"Resources":{"LogicalTestTable":{"Type":"AWS::Serverless::SimpleTable","Properties":{"TableName":"TestTable","PrimaryKey":{"Name":"pk","Type":"String"}}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [] } - testDeployment.additionalResourcesFunction = { - return [Resource.table(logicalName: "LogicalTestTable", - physicalName: "TestTable", - primaryKeyName: "pk", - primaryKeyType: "String")] - } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + let testDeployment = MockDeploymentDescriptor(withFunction: false, + additionalResources: + [.table(logicalName: "LogicalTestTable", + physicalName: "TestTable", + primaryKeyName: "pk", + primaryKeyType: "String")] + ) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - + func testSQSQueueResource() { - + // given let expected = """ - LogicalQueueName: - Type: AWS::SQS::Queue - Properties: - QueueName: queue-name +"Resources":{"LogicalQueueName":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"queue-name"}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [] } - testDeployment.additionalResourcesFunction = { - return [Resource.queue(name: "LogicalQueueName", - properties: SQSResourceProperties(queueName: "queue-name")) - ] - } - - do { - // when - let samYaml = try testDeployment.toYaml() + + let testDeployment = MockDeploymentDescriptor(withFunction: false, + additionalResources: + [.queue(name: "LogicalQueueName", + properties: SQSResourceProperties(queueName: "queue-name"))] - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + ) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - + func testHttpApiEventSourceCatchAll() { - + // given let expected = """ - Events: - HttpApiEvent: - Type: HttpApi +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [ .httpApi() ] } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + let testDeployment = MockDeploymentDescriptor(withFunction: true, + eventSource: [ .httpApi() ] ) + + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - + func testHttpApiEventSourceSpecific() { - + // given let expected = """ - HttpApiEvent: - Type: HttpApi - Properties: - Method: GET - Path: /test +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [ .httpApi(method: .GET, - path: "/test") ] } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + let testDeployment = MockDeploymentDescriptor(withFunction: true, + eventSource: [ .httpApi(method: .GET, path: "/test") ]) + + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - +// func testSQSEventSourceWithArn() { - + // given let expected = """ - Events: - SQSEvent: - Type: SQS - Properties: - Queue: arn:aws:sqs:eu-central-1:012345678901:lambda-test +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + let testDeployment = MockDeploymentDescriptor(withFunction: true, + eventSource: [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] ) + + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - + func testSQSEventSourceWithoutArn() { - + // given - let expected = """ - Events: - SQSEvent: - Type: SQS - Properties: - Queue: - Fn::GetAtt: - - QueueQueueLambdaTest - - Arn + var expected = """ +"QueueQueueLambdaTest":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"queue-lambda-test"}} """ - let expectedResource = """ - QueueQueueLambdaTest: - Type: AWS::SQS::Queue - Properties: - QueueName: queue-lambda-test + + let testDeployment = MockDeploymentDescriptor(withFunction: true, + eventSource: [ .sqs(queue: "queue-lambda-test") ] ) + + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) + + expected = """ +"Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]}}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable.none() } - testDeployment.eventSourceFunction = { _ in [ .sqs(queue: "queue-lambda-test") ] } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expected)) - XCTAssertTrue(samYaml.contains(expectedResource)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } - + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } - + func testEnvironmentVariablesString() { - + // given - let expectedOne = """ - Environment: - Variables: - TEST2_VAR: TEST2_VALUE - TEST1_VAR: TEST1_VALUE + let expectedinOrder = """ +"Environment":{"Variables":{"TEST2_VAR":"TEST2_VALUE","TEST1_VAR":"TEST1_VALUE"}} """ - let expectedTwo = """ - Environment: - Variables: - TEST1_VAR: TEST1_VALUE - TEST2_VAR: TEST2_VALUE + let expectedOutOfOrder = """ +"Environment":{"Variables":{"TEST1_VAR":"TEST1_VALUE","TEST2_VAR":"TEST2_VALUE"}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in EnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", - "TEST2_VAR": "TEST2_VALUE"]) } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expectedOne) || samYaml.contains(expectedTwo)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + + let testDeployment = MockDeploymentDescriptor(withFunction: true, + environmentVariable: EnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", + "TEST2_VAR": "TEST2_VALUE"]) ) + + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expectedinOrder) + || + self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expectedOutOfOrder) + ) } - + func testEnvironmentVariablesArray() { - + // given - let expectedOne = """ - Environment: - Variables: - TEST1_VAR: - Ref: TEST1_VALUE + let expected = """ +"Environment":{"Variables":{"TEST1_VAR":{"Ref":"TEST1_VALUE"}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in - var envVars = EnvironmentVariable() - envVars.append("TEST1_VAR", ["Ref" : "TEST1_VALUE"]) - return envVars - } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expectedOne)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + var envVar = EnvironmentVariable() + envVar.append("TEST1_VAR", ["Ref" : "TEST1_VALUE"]) + let testDeployment = MockDeploymentDescriptor(withFunction: true, + environmentVariable: envVar ) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } func testEnvironmentVariablesDictionary() { - + // given - let expectedOne = """ - Environment: - Variables: - TEST1_VAR: - Fn::GetAtt: - - TEST1_VALUE - - Arn + let expected = """ +"Environment":{"Variables":{"TEST1_VAR":{"Fn::GetAtt":["TEST1_VALUE","Arn"]}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in - var envVars = EnvironmentVariable() - envVars.append("TEST1_VAR", ["Fn::GetAtt" : ["TEST1_VALUE", "Arn"]]) - return envVars - } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expectedOne)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + var envVar = EnvironmentVariable() + envVar.append("TEST1_VAR", ["Fn::GetAtt" : ["TEST1_VALUE", "Arn"]]) + let testDeployment = MockDeploymentDescriptor(withFunction: true, + environmentVariable: envVar ) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } func testEnvironmentVariablesResource() { - + // given - let expectedOne = """ - Environment: - Variables: - TEST1_VAR: - Ref: LogicalName + let expected = """ +"Environment":{"Variables":{"TEST1_VAR":{"Ref":"LogicalName"}}} """ - - var testDeployment = MockDeploymentDescriptor() - testDeployment.environmentVariableFunction = { _ in - let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") - var envVars = EnvironmentVariable() - envVars.append("TEST1_VAR", resource) - return envVars - } - - do { - // when - let samYaml = try testDeployment.toYaml() - - // then - XCTAssertTrue(samYaml.contains(expectedOne)) - } catch { - XCTFail("toYaml should not throw an exceptoon") - } + + let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") + var envVar = EnvironmentVariable() + envVar.append("TEST1_VAR", resource) + let testDeployment = MockDeploymentDescriptor(withFunction: true, + environmentVariable: envVar ) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) } 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) } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index 7d522de5..fa73b8b1 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -15,36 +15,34 @@ import Foundation import AWSLambdaDeploymentDescriptor -struct MockDeploymentDescriptor: DeploymentDescriptor { +struct MockDeploymentDescriptor { - var eventSourceFunction: ( (String) -> [EventSource] )? - var environmentVariableFunction: ( (String) -> EnvironmentVariable )? - var additionalResourcesFunction: ( () -> [Resource] )? - - // returns the event sources for the given Lambda function - func eventSources(_ lambdaName: String) -> [EventSource] { - if let eventSourceFunction { - return eventSourceFunction(lambdaName) - } else { - return [] - } - } - - // returns environment variables to associate with the given Lambda function - func environmentVariables(_ lambdaName: String) -> EnvironmentVariable { - if let environmentVariableFunction { - return environmentVariableFunction(lambdaName) + let deploymentDescriptor : DeploymentDefinition + + init(withFunction: Bool = true, + eventSource: [EventSource]? = nil, + environmentVariable: EnvironmentVariable? = nil, + additionalResources: [Resource]? = nil) + { + if withFunction { + self.deploymentDescriptor = DeploymentDefinition( + description: "A SAM template to deploy a Swift Lambda function", + functions: [ + .function( + name: "TestLambda", + eventSources: eventSource ?? [], + environment: environmentVariable ?? EnvironmentVariable.none + ) + ], + resources: additionalResources ?? [] + ) } else { - return EnvironmentVariable.none() - } - } - - // returns environment variables to associate with the given Lambda function - func addResource() -> [Resource] { - if let additionalResourcesFunction { - return additionalResourcesFunction() - } else { - return [] + self.deploymentDescriptor = DeploymentDefinition( + description: "A SAM template to deploy a Swift Lambda function", + functions: [], + resources: additionalResources ?? [] + ) } } } + From 8a816c5f34d3aefd4d299b6cde42efa651ad9c02 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 15:14:42 +0100 Subject: [PATCH 22/79] Factor out common code between the two plugins --- Plugins/AWSLambdaDeployer/Plugin.swift | 150 +++----------------- Plugins/AWSLambdaDeployer/PluginUtils.swift | 1 + Plugins/AWSLambdaPackager/Plugin.swift | 130 ++--------------- Plugins/AWSLambdaPackager/PluginUtils.swift | 129 +++++++++++++++++ 4 files changed, 160 insertions(+), 250 deletions(-) create mode 120000 Plugins/AWSLambdaDeployer/PluginUtils.swift create mode 100644 Plugins/AWSLambdaPackager/PluginUtils.swift diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 06471db7..1f00209d 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -79,7 +79,7 @@ struct AWSLambdaPackager: CommandPlugin { // 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 PluginError.deployswiftDoesNotExist + throw DeployerPluginError.deployswiftDoesNotExist } do { @@ -105,7 +105,7 @@ struct AWSLambdaPackager: CommandPlugin { let helperFileUrl = URL(fileURLWithPath: helperFilePath) try helperCmd.write(to: helperFileUrl, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helperFilePath) - let samDeploymentDescriptor = try execute( + let samDeploymentDescriptor = try Utils.execute( executable: Path("/bin/bash"), arguments: ["-c", helperFilePath], customWorkingDirectory: projectDirectory, @@ -124,14 +124,14 @@ struct AWSLambdaPackager: CommandPlugin { to: samDeploymentDescriptorFileUrl, atomically: true, encoding: .utf8) verboseLogging ? print("\(samDeploymentDescriptorFilePath)") : nil - } catch let error as PluginError { + } 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 PluginError.error(error) + throw DeployerPluginError.error(error) } catch { print("Unexpected error : \(error)") - throw PluginError.error(error) + throw DeployerPluginError.error(error) } } @@ -144,7 +144,7 @@ struct AWSLambdaPackager: CommandPlugin { guard let executable = try? context.tool(named: executableName) else { print("Can not find `\(executableName)` executable.") print(helpMessage) - throw PluginError.toolNotFound(executableName) + throw DeployerPluginError.toolNotFound(executableName) } if verboseLogging { @@ -164,21 +164,21 @@ struct AWSLambdaPackager: CommandPlugin { print("-------------------------------------------------------------------------") do { - try execute( + try Utils.execute( executable: samExecutablePath, arguments: ["validate", "-t", samDeploymentDescriptorFilePath, "--lint"], logLevel: verboseLogging ? .debug : .silent) - } catch let error as PluginError { + } 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 PluginError.error(error) + throw DeployerPluginError.error(error) } catch { print("Unexpected error : \(error)") - throw PluginError.error(error) + throw DeployerPluginError.error(error) } } @@ -196,7 +196,7 @@ struct AWSLambdaPackager: CommandPlugin { print("-------------------------------------------------------------------------") do { - try execute( + try Utils.execute( executable: samExecutablePath, arguments: ["deploy", "-t", samDeploymentDescriptorFilePath, @@ -204,14 +204,14 @@ struct AWSLambdaPackager: CommandPlugin { "--capabilities", "CAPABILITY_IAM", "--resolve-s3"], logLevel: verboseLogging ? .debug : .silent) - } catch let error as PluginError { + } 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 PluginError.error(error) + throw DeployerPluginError.error(error) } catch { print("Unexpected error : \(error)") - throw PluginError.error(error) + throw DeployerPluginError.error(error) } } } @@ -251,7 +251,7 @@ private struct Configuration: CustomStringConvertible { guard let buildConfiguration = PackageManager.BuildConfiguration(rawValue: buildConfigurationName) else { - throw PluginError.invalidArgument( + throw DeployerPluginError.invalidArgument( "invalid build configuration named '\(buildConfigurationName)'") } self.buildConfiguration = buildConfiguration @@ -267,7 +267,7 @@ private struct Configuration: CustomStringConvertible { } var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: self.archiveDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { - throw PluginError.invalidArgument( + throw DeployerPluginError.invalidArgument( "invalid archive directory: \(self.archiveDirectory)\nthe directory does not exists") } @@ -302,9 +302,8 @@ private struct Configuration: CustomStringConvertible { } } -private enum PluginError: Error, CustomStringConvertible { +private enum DeployerPluginError: Error, CustomStringConvertible { case invalidArgument(String) - case processFailed([String], Int32) case toolNotFound(String) case deployswiftDoesNotExist case error(Error) @@ -313,8 +312,6 @@ private enum PluginError: Error, CustomStringConvertible { switch self { case .invalidArgument(let description): return description - case .processFailed(let command, let code): - return "\(command.joined(separator: " ")) failed with exit code \(code)" case .toolNotFound(let tool): return tool case .deployswiftDoesNotExist: @@ -325,116 +322,3 @@ private enum PluginError: Error, CustomStringConvertible { } } -// ************************************************************** -// Below this line, the code is copied from the archiver plugin -// ************************************************************** -@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 PluginError.processFailed([executable.string] + arguments, process.terminationStatus) - } - - return output -} - -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 - } -} - - -// ************************************************************** -// end copied code -// ************************************************************** 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/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index 2a3f119e..bce9f588 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -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 { @@ -88,7 +87,7 @@ struct AWSLambdaPackager: CommandPlugin { // 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 @@ -96,13 +95,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)) @@ -111,7 +110,7 @@ struct AWSLambdaPackager: CommandPlugin { for product in products { print("building \"\(product.name)\"") let buildCommand = "swift build -c \(buildConfiguration.rawValue) --product \(product.name) --static-swift-stdlib" - 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 @@ -119,7 +118,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 } @@ -149,7 +148,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 } @@ -192,7 +191,7 @@ struct AWSLambdaPackager: CommandPlugin { #endif // run the zip tool - try self.execute( + try Utils.execute( executable: zipToolPath, arguments: arguments, logLevel: verboseLogging ? .debug : .silent @@ -203,77 +202,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") @@ -308,7 +236,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 { @@ -320,7 +248,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 @@ -331,7 +259,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 { @@ -339,7 +267,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 @@ -365,43 +293,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 { @@ -417,8 +315,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..810bd21f --- /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) + } + + return output + } + + enum ProcessError: Error, CustomStringConvertible { + case processFailed([String], Int32) + + 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 + } + } +} From d09ecbe35bc8cb2c72f3afffd5dec4d3100f87dd Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 16:12:15 +0100 Subject: [PATCH 23/79] remove dependency on URL --- Plugins/AWSLambdaDeployer/Plugin.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 1f00209d..665d16c4 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -102,9 +102,9 @@ struct AWSLambdaPackager: CommandPlugin { // create and execute a plugin helper to run the "swift" command let helperFilePath = "\(projectDirectory)/compile.sh" - let helperFileUrl = URL(fileURLWithPath: helperFilePath) - try helperCmd.write(to: helperFileUrl, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helperFilePath) + FileManager.default.createFile(atPath: helperFilePath, + contents: helperCmd.data(using: .utf8), + attributes: [.posixPermissions: 0o755]) let samDeploymentDescriptor = try Utils.execute( executable: Path("/bin/bash"), arguments: ["-c", helperFilePath], @@ -116,12 +116,11 @@ struct AWSLambdaPackager: CommandPlugin { // arguments: Array(cmd.dropFirst()), // customWorkingDirectory: context.package.directory, // logLevel: configuration.verboseLogging ? .debug : .silent) - try FileManager.default.removeItem(at: helperFileUrl) + try FileManager.default.removeItem(atPath: helperFilePath) // write the generated SAM deployment decsriptor to disk - let samDeploymentDescriptorFileUrl = URL(fileURLWithPath: samDeploymentDescriptorFilePath) - try samDeploymentDescriptor.write( - to: samDeploymentDescriptorFileUrl, atomically: true, encoding: .utf8) + FileManager.default.createFile(atPath: samDeploymentDescriptorFilePath, + contents: samDeploymentDescriptor.data(using: .utf8)) verboseLogging ? print("\(samDeploymentDescriptorFilePath)") : nil } catch let error as DeployerPluginError { From 7180a4ad73a245a9e37ce6b8075c6c58db663fd4 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 16:48:22 +0100 Subject: [PATCH 24/79] add help option to the deployer plugin --- Plugins/AWSLambdaDeployer/Plugin.swift | 41 ++++++++++++++++++++++++-- Plugins/AWSLambdaDeployer/README.md | 32 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 665d16c4..007083f1 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -21,6 +21,10 @@ struct AWSLambdaPackager: 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)/sam.json" @@ -213,10 +217,39 @@ struct AWSLambdaPackager: CommandPlugin { throw DeployerPluginError.error(error) } } + + 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] [--nodeploy] [--configuration ] [--archive-path ] [--stack-name ] + +OPTIONS: + --verbose Produce verbose output for debugging. + --nodeploy Generates the JSON deployment descriptor, but do not deploy. + --configuration + Build for a specific configuration. + Must be aligned with what was used to build and package. + Valid values : [ debug, release ] (default: debug) + --archive-path + The path where the archive plugin created the ZIP archive. + Must be aligned with the value passed to archive --output-path. + (default: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager) + --stack-name + The name of the CloudFormation stack when deploying. + (default: the project name) + --help Show help information. +""") + } } private struct Configuration: CustomStringConvertible { public let buildConfiguration: PackageManager.BuildConfiguration + public let help: Bool public let noDeploy: Bool public let verboseLogging: Bool public let archiveDirectory: String @@ -237,7 +270,11 @@ private struct Configuration: CustomStringConvertible { let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 let configurationArgument = argumentExtractor.extractOption(named: "configuration") let archiveDirectoryArgument = argumentExtractor.extractOption(named: "archive-path") - let stackNamenArgument = argumentExtractor.extractOption(named: "stackname") + let stackNameArgument = argumentExtractor.extractOption(named: "stackname") + let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 + + // help required ? + self.help = helpArgument // define deployment option self.noDeploy = nodeployArgument @@ -271,7 +308,7 @@ private struct Configuration: CustomStringConvertible { } // infer or consume stackname - if let stackName = stackNamenArgument.first { + if let stackName = stackNameArgument.first { self.stackName = stackName } else { self.stackName = context.package.displayName diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index f47a3087..470e84db 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -136,6 +136,38 @@ REPORT RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 Init Duration: 0.44 ms {"headers":{"content-type":"application\/json"},"body":"{\"isBase64Encoded\":false,\"headers\":{\"x-forwarded-for\":\"90.103.90.59\",\"sec-fetch-site\":\"none\",\"x-amzn-trace-id\":\"Root=1-63a29de7-371407804cbdf89323be4902\",\"content-length\":\"0\",\"host\":\"x6v980zzkh.execute-api.eu-central-1.amazonaws.com\",\"x-forwarded-port\":\"443\",\"accept\":\"text\\\/html,application\\\/xhtml+xml,application\\\/xml;q=0.9,image\\\/avif,image\\\/webp,*\\\/*;q=0.8\",\"sec-fetch-user\":\"?1\",\"user-agent\":\"Mozilla\\\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\\\/20100101 Firefox\\\/108.0\",\"accept-language\":\"en-US,en;q=0.8,fr-FR;q=0.5,fr;q=0.3\",\"sec-fetch-dest\":\"document\",\"dnt\":\"1\",\"sec-fetch-mode\":\"navigate\",\"x-forwarded-proto\":\"https\",\"accept-encoding\":\"gzip, deflate, br\",\"upgrade-insecure-requests\":\"1\"},\"version\":\"2.0\",\"queryStringParameters\":{\"arg1\":\"value1\",\"arg2\":\"value2\"},\"routeKey\":\"$default\",\"requestContext\":{\"domainPrefix\":\"x6v980zzkh\",\"stage\":\"$default\",\"timeEpoch\":1671601639995,\"apiId\":\"x6v980zzkh\",\"http\":{\"protocol\":\"HTTP\\\/1.1\",\"sourceIp\":\"90.103.90.59\",\"method\":\"GET\",\"userAgent\":\"Mozilla\\\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\\\/20100101 Firefox\\\/108.0\",\"path\":\"\\\/test\"},\"time\":\"21\\\/Dec\\\/2022:05:47:19 +0000\",\"domainName\":\"x6v980zzkh.execute-api.eu-central-1.amazonaws.com\",\"requestId\":\"de2cRil5liAEM5Q=\",\"accountId\":\"486652066693\"},\"rawQueryString\":\"arg1=value1&arg2=value2\",\"rawPath\":\"\\\/test\"}","statusCode":202}% ``` +### 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] [--nodeploy] [--configuration ] [--archive-path ] [--stack-name ] + +OPTIONS: + --verbose Produce verbose output for debugging. + --nodeploy Generates the JSON deployment descriptor, but do not deploy. + --configuration + Build for a specific configuration. + Must be aligned with what was used to build and package. + Valid values : [ debug, release ] (default: debug) + --archive-path + The path where the archive plugin created the ZIP archive. + Must be aligned with the value passed to archive --output-path. + (default: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager) + --stack-name + The name of the CloudFormation stack when deploying. + (default: the project name) + --help Show help information. +``` + + ### What is missing ? If this proposal is accepted, 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) From fac67199865ac7eebad808565ccc29c84d27438d Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 16:48:37 +0100 Subject: [PATCH 25/79] refactor platform architecture for unit tests --- .../DeploymentDescriptorTests.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index ee0b1401..855b0609 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -19,6 +19,12 @@ final class DeploymentDescriptorTest: XCTestCase { var originalCommandLineArgs: [String] = [] +#if arch(arm64) + private let architecture = "arm64" +#else + private let architecture = "x86_64" +#endif + override func setUp() { // save the original Command Line args, just in case originalCommandLineArgs = CommandLine.arguments @@ -51,11 +57,6 @@ final class DeploymentDescriptorTest: XCTestCase { func testLambdaFunctionResource() { // given -#if arch(arm64) - let architecture = "arm64" -#else - let architecture = "x86_64" -#endif let expected = """ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(architecture)"]}}} """ @@ -103,7 +104,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(self.architecture)"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -117,7 +118,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(self.architecture)"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -131,7 +132,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(self.architecture)"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, From c712083c0d10237371eaf7d5cfa29c6410160c45 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 17:40:39 +0100 Subject: [PATCH 26/79] refactor for better readability --- .../DeploymentDefinition.swift | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 88a65d70..dab4ca19 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -18,7 +18,19 @@ public struct DeploymentDefinition { resources: [Resource]) { - var additionalresources = resources + let functionResources = createResources(for: functions) + + self.deployment = SAMDeployment(description: description, + resources: functionResources + resources) + + //TODO: add default output section to return the URL of the API Gateway + + dumpPackageAtExit(self, to: 1) // 1 = stdout + } + + // create one Resource per function + additional resource for the function dependencies (ex: a SQS queue) + private func createResources(for functions: [Function]) -> [Resource] { + var additionalresources : [Resource] = [] let functionResources = functions.compactMap { function in // compute the path for the lambda archive @@ -53,13 +65,7 @@ public struct DeploymentDefinition { eventSources: function.eventSources, environment: function.environment) } - - self.deployment = SAMDeployment(description: description, - resources: functionResources + additionalresources) - - //TODO: add default output section to return the URL of the API Gateway - - dumpPackageAtExit(self, to: 1) // 1 = stdout + return functionResources + additionalresources } // When SQS event source is specified, the Lambda function developer From f3f4021dbebb086f4f33efac58b3b4e6a5cf4d1e Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 31 Jan 2023 18:18:10 +0100 Subject: [PATCH 27/79] function architecture is now a parameter that function developers can pass in Deploy.swift --- Plugins/AWSLambdaDeployer/Plugin.swift | 2 +- Plugins/AWSLambdaDeployer/README.md | 1 + .../DeploymentDefinition.swift | 5 +++ .../DeploymentDescriptor.swift | 32 +++++++++++++------ .../DeploymentDescriptorTests.swift | 26 +++++++++------ .../MockedDeploymentDescriptor.swift | 2 ++ 6 files changed, 48 insertions(+), 20 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 007083f1..9d52ccce 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -234,7 +234,7 @@ OPTIONS: --configuration Build for a specific configuration. Must be aligned with what was used to build and package. - Valid values : [ debug, release ] (default: debug) + Valid values: [ debug, release ] (default: debug) --archive-path The path where the archive plugin created the ZIP archive. Must be aligned with the value passed to archive --output-path. diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index 470e84db..a4a357db 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -84,6 +84,7 @@ let _ = DeploymentDefinition( functions: [ .function( name: "HttpApiLambda", + architecture: .arm64, // optional, defaults to current build platform eventSources: [ .httpApi(method: .GET, path: "/test"), ], diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index dab4ca19..765bc208 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -30,6 +30,7 @@ public struct DeploymentDefinition { // create one Resource per function + additional resource for the function dependencies (ex: a SQS queue) private func createResources(for functions: [Function]) -> [Resource] { + var additionalresources : [Resource] = [] let functionResources = functions.compactMap { function in @@ -61,6 +62,7 @@ public struct DeploymentDefinition { additionalresources += self.explicitQueueResources(function: function) return Resource.serverlessFunction(name: function.name, + architecture: function.architecture, codeUri: lambdaPackage, eventSources: function.eventSources, environment: function.environment) @@ -105,9 +107,11 @@ public struct DeploymentDefinition { //TODO: should I move this to the Resource() struct ? Then I need a way to add CodeUri at a later stage public struct Function { let name: String + let architecture: Architectures let eventSources: [EventSource] let environment: EnvironmentVariable public static func function(name : String, + architecture: Architectures = Architectures.defaultArchitecture(), eventSources: [EventSource], environment: EnvironmentVariable = .none) -> Function { @@ -121,6 +125,7 @@ public struct Function { } return self.init(name: name, + architecture: architecture, eventSources: eventSources, environment: environment) } diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 7c596c2e..f997c2f9 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -89,11 +89,13 @@ public struct Resource: SAMResource, Equatable { extension Resource { public static func serverlessFunction(name: String, + architecture: Architectures, codeUri: String?, eventSources: [EventSource] = [], environment: EnvironmentVariable = .none) -> Resource { let properties = ServerlessFunctionProperties(codeUri: codeUri, + architecture: architecture, eventSources: eventSources, environment: environment) return Resource(type: "AWS::Serverless::Function", @@ -102,11 +104,26 @@ extension Resource { } } -public struct ServerlessFunctionProperties: SAMResourceProperties { - public enum Architectures: String, Encodable { - case x64 = "x86_64" - case arm64 = "arm64" +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 + return .x64 +#endif + } + + // valid values for error and help message + public static func validValues() -> String { + return Architectures.allCases.map { $0.rawValue }.joined(separator: ", ") } +} + +public struct ServerlessFunctionProperties: SAMResourceProperties { let architectures: [Architectures] let handler: String let runtime: String @@ -116,14 +133,11 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { var environment: EnvironmentVariable public init(codeUri: String?, + architecture: Architectures, eventSources: [EventSource] = [], environment: EnvironmentVariable = .none) { -#if arch(arm64) //when we build on Arm, we deploy on Arm - self.architectures = [.arm64] -#else - self.architectures = [.x64] -#endif + self.architectures = [architecture] self.handler = "Provided" self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 self.autoPublishAlias = "Live" diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 855b0609..3ca5af40 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -19,12 +19,6 @@ final class DeploymentDescriptorTest: XCTestCase { var originalCommandLineArgs: [String] = [] -#if arch(arm64) - private let architecture = "arm64" -#else - private let architecture = "x86_64" -#endif - override func setUp() { // save the original Command Line args, just in case originalCommandLineArgs = CommandLine.arguments @@ -58,13 +52,25 @@ final class DeploymentDescriptorTest: XCTestCase { // given let expected = """ -function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(architecture)"]}}} +function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true) XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, expected: expected)) } + func testLambdaFunctionWithSpecificArchitectures() { + + // given + let expected = """ +function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.x64.rawValue)"]}}} +""" + let testDeployment = MockDeploymentDescriptor(withFunction: true, + architecture: .x64) + XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + expected: expected)) + } + func testSimpleTableResource() { // given @@ -104,7 +110,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(self.architecture)"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -118,7 +124,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(self.architecture)"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -132,7 +138,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(self.architecture)"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index fa73b8b1..47a3e923 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -20,6 +20,7 @@ struct MockDeploymentDescriptor { let deploymentDescriptor : DeploymentDefinition init(withFunction: Bool = true, + architecture: Architectures = Architectures.defaultArchitecture(), eventSource: [EventSource]? = nil, environmentVariable: EnvironmentVariable? = nil, additionalResources: [Resource]? = nil) @@ -30,6 +31,7 @@ struct MockDeploymentDescriptor { functions: [ .function( name: "TestLambda", + architecture: architecture, eventSources: eventSource ?? [], environment: environmentVariable ?? EnvironmentVariable.none ) From b8cb5b064a0fec61884def738ae6b4b55879ddd9 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 09:14:24 +0100 Subject: [PATCH 28/79] add sample architecture entry --- Examples/SAM/Deploy.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift index c8848234..94479ade 100644 --- a/Examples/SAM/Deploy.swift +++ b/Examples/SAM/Deploy.swift @@ -16,6 +16,9 @@ let _ = DeploymentDefinition( .function( // the name of the function name: "HttpApiLambda", + + // the AWS Lambda architecture (defaults to current build platform) + //architecture: .x64, // the event sources eventSources: [ From 464fd01bb4d10060c4bb19f0f42fcac5270eeae7 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 09:17:12 +0100 Subject: [PATCH 29/79] add example tests for the two Lambda functions --- Examples/SAM/HttpApiLambda/Lambda.swift | 56 ++++++++++--------- Examples/SAM/Makefile | 3 + Examples/SAM/Package.swift | 12 ++++ Examples/SAM/SQSLambda/Lambda.swift | 36 ++++++------ .../Tests/LambdaTests/HttpApiLambdaTest.swift | 46 +++++++++++++++ .../SAM/Tests/LambdaTests/LambdaTest.swift | 22 ++++++++ .../SAM/Tests/LambdaTests/SQSLambdaTest.swift | 40 +++++++++++++ .../SAM/Tests/LambdaTests/data/apiv2.json | 1 + Examples/SAM/Tests/LambdaTests/data/sqs.json | 20 +++++++ 9 files changed, 191 insertions(+), 45 deletions(-) create mode 100644 Examples/SAM/Tests/LambdaTests/HttpApiLambdaTest.swift create mode 100644 Examples/SAM/Tests/LambdaTests/LambdaTest.swift create mode 100644 Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift create mode 100644 Examples/SAM/Tests/LambdaTests/data/apiv2.json create mode 100644 Examples/SAM/Tests/LambdaTests/data/sqs.json diff --git a/Examples/SAM/HttpApiLambda/Lambda.swift b/Examples/SAM/HttpApiLambda/Lambda.swift index f826828d..dfbf8710 100644 --- a/Examples/SAM/HttpApiLambda/Lambda.swift +++ b/Examples/SAM/HttpApiLambda/Lambda.swift @@ -18,32 +18,34 @@ 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)") + 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: .ok, headers: header, body: response) + + } catch { + // should never happen as the decoding was made by the runtime + // when the inoput event is malformed, this function is not even called + header["content-type"] = "text/plain" + return Output(statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") + } } - } } diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile index b663eb68..dcb2cabd 100644 --- a/Examples/SAM/Makefile +++ b/Examples/SAM/Makefile @@ -4,6 +4,9 @@ build: update: swift package update +test: + swift test + release: swift build -c release diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index 6e7e2a9f..b59d9d9b 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -36,6 +36,7 @@ let package = Package( // .package(url: "https://github.com/sebsto/swift-aws-lambda-runtime.git", branch: "sebsto/deployerplugin"), // .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), .package(name: "swift-aws-lambda-runtime", path: "../.."), +// .package(url: "../..", branch: "sebsto/deployerplugin"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") ], targets: [ @@ -54,6 +55,17 @@ let package = Package( .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") ] + deploymentDescriptorDependency, path: "./SQSLambda" + ), + .testTarget( + name: "LambdaTests", + dependencies: [ + "HttpApiLambda", "SQSLambda", + .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime") + ], + resources: [ + .process("data/apiv2.json"), + .process("data/sqs.json") + ] ) ] ) diff --git a/Examples/SAM/SQSLambda/Lambda.swift b/Examples/SAM/SQSLambda/Lambda.swift index bb1a2ab0..87be9335 100644 --- a/Examples/SAM/SQSLambda/Lambda.swift +++ b/Examples/SAM/SQSLambda/Lambda.swift @@ -18,23 +18,23 @@ import Foundation @main struct SQSLambda: SimpleLambdaHandler { - 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)") + 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..344dcc82 --- /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..2b7d40d4 --- /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)) + } +} \ No newline at end of file diff --git a/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift b/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift new file mode 100644 index 00000000..e923ca7a --- /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 + + } +} \ No newline at end of file diff --git a/Examples/SAM/Tests/LambdaTests/data/apiv2.json b/Examples/SAM/Tests/LambdaTests/data/apiv2.json new file mode 100644 index 00000000..c6c61674 --- /dev/null +++ b/Examples/SAM/Tests/LambdaTests/data/apiv2.json @@ -0,0 +1 @@ +{"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":"90.103.90.59","path":"\/test","method":"GET"},"domainName":"x6v980zzkh.execute-api.eu-central-1.amazonaws.com","accountId":"486652066693","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":"90.103.90.59","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"} 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" + } + ] +} From 1d720a2158629b3be14402bd9be1c871d270116f Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 09:56:03 +0100 Subject: [PATCH 30/79] cleaner fix for --static-swift-stdlib issue on linux --- Examples/SAM/Package.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index b59d9d9b..745f1b85 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -16,11 +16,6 @@ import PackageDescription -var deploymentDescriptorDependency : [Target.Dependency] = [] -#if !os(Linux) - deploymentDescriptorDependency = [.product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime")] -#endif - let package = Package( name: "swift-aws-lambda-runtime-example", platforms: [ @@ -44,16 +39,22 @@ let package = Package( name: "HttpApiLambda", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") - ] + deploymentDescriptorDependency, + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + // linking the dynamic library does not work on Linux with --static-swift-stdlib because it is a dynamic library, + // but we don't need it for packaging. Making it conditional will generate smaller ZIP files + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) + ], path: "./HttpApiLambda" ), .executableTarget( name: "SQSLambda", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") - ] + deploymentDescriptorDependency, + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + // linking the dynamic library does not work on Linux with --static-swift-stdlib because it is a dynamic library, + // but we don't need it for packaging. Making it conditional will generate smaller ZIP files + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) + ] , path: "./SQSLambda" ), .testTarget( From ca4d1c24aa02efb2c2af34dc92d1c87eb0dbac58 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 10:10:54 +0100 Subject: [PATCH 31/79] update README to reflect latest changes --- Plugins/AWSLambdaDeployer/README.md | 32 +++++------------------------ 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index a4a357db..bf13ea2a 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -22,11 +22,11 @@ SAM is already broadly adopted, well maintained and documented. It does the job. I added two targets to `Package.swift` : -- `AWSLambdaDeployer` is the plugin itself. I followed the same structure and code as the `archive` plugin (similar configuration and shell code). +- `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 JSON 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 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 contains minimum code to describe the required HTTP API and SQS code and to allow `AWSLambdaDeploymentDescriptor` to generate the SAM deployment descriptor. +I 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: @@ -74,7 +74,7 @@ struct HttpApiLambda: SimpleLambdaHandler { 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 a new `Deploy.swift` file at the top of my project. Here is a simple deployment file. A more complex one is provided in the commit. +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 @@ -107,34 +107,12 @@ swift package --disable-sandbox archive 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 deploy the template. +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 sam.yaml -e test/apiv2.json HttpApiLambda -Invoking Provided (provided.al2) -Decompressing /Users/stormacq/Documents/amazon/code/lambda/swift/swift-aws-lambda-runtime/Examples/SAM/.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/HttpApiLambda/HttpApiLambda.zip -Skip pulling image and use local one: public.ecr.aws/sam/emulation-provided.al2:rapid-1.67.0-arm64. - -Mounting /private/var/folders/14/nwpsn4b504gfp02_mrbyd2jr0000gr/T/tmpc6ajvoxv as /var/task:ro,delegated inside runtime container -START RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 Version: $LATEST -2022-12-23T14:28:34+0000 info Lambda : [AWSLambdaRuntimeCore] lambda runtime starting with LambdaConfiguration - General(logLevel: debug)) - Lifecycle(id: 157597332736, maxTimes: 0, stopSignal: TERM) - RuntimeEngine(ip: 127.0.0.1, port: 9001, requestTimeout: nil -2022-12-23T14:28:34+0000 debug Lambda : lifecycleId=157597332736 [AWSLambdaRuntimeCore] initializing lambda -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] lambda invocation sequence starting -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] requesting work from lambda runtime engine using /2018-06-01/runtime/invocation/next -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] sending invocation to lambda handler -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [HttpApiLambda] HTTP API Message received -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] reporting results to lambda runtime engine using /2018-06-01/runtime/invocation/23cb7237-5c46-420a-b311-45ae9d4d19b7/response -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=0 [AWSLambdaRuntimeCore] lambda invocation sequence completed successfully -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=1 [AWSLambdaRuntimeCore] lambda invocation sequence starting -2022-12-23T14:28:34+0000 debug Lambda : lifecycleIteration=1 [AWSLambdaRuntimeCore] requesting work from lambda runtime engine using /2018-06-01/runtime/invocation/next -END RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 -REPORT RequestId: 23cb7237-5c46-420a-b311-45ae9d4d19b7 Init Duration: 0.44 ms Duration: 115.33 ms Billed Duration: 116 ms Memory Size: 128 MB Max Memory Used: 128 MB -{"headers":{"content-type":"application\/json"},"body":"{\"isBase64Encoded\":false,\"headers\":{\"x-forwarded-for\":\"90.103.90.59\",\"sec-fetch-site\":\"none\",\"x-amzn-trace-id\":\"Root=1-63a29de7-371407804cbdf89323be4902\",\"content-length\":\"0\",\"host\":\"x6v980zzkh.execute-api.eu-central-1.amazonaws.com\",\"x-forwarded-port\":\"443\",\"accept\":\"text\\\/html,application\\\/xhtml+xml,application\\\/xml;q=0.9,image\\\/avif,image\\\/webp,*\\\/*;q=0.8\",\"sec-fetch-user\":\"?1\",\"user-agent\":\"Mozilla\\\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\\\/20100101 Firefox\\\/108.0\",\"accept-language\":\"en-US,en;q=0.8,fr-FR;q=0.5,fr;q=0.3\",\"sec-fetch-dest\":\"document\",\"dnt\":\"1\",\"sec-fetch-mode\":\"navigate\",\"x-forwarded-proto\":\"https\",\"accept-encoding\":\"gzip, deflate, br\",\"upgrade-insecure-requests\":\"1\"},\"version\":\"2.0\",\"queryStringParameters\":{\"arg1\":\"value1\",\"arg2\":\"value2\"},\"routeKey\":\"$default\",\"requestContext\":{\"domainPrefix\":\"x6v980zzkh\",\"stage\":\"$default\",\"timeEpoch\":1671601639995,\"apiId\":\"x6v980zzkh\",\"http\":{\"protocol\":\"HTTP\\\/1.1\",\"sourceIp\":\"90.103.90.59\",\"method\":\"GET\",\"userAgent\":\"Mozilla\\\/5.0 (Macintosh; Intel Mac OS X 10.15; rv:108.0) Gecko\\\/20100101 Firefox\\\/108.0\",\"path\":\"\\\/test\"},\"time\":\"21\\\/Dec\\\/2022:05:47:19 +0000\",\"domainName\":\"x6v980zzkh.execute-api.eu-central-1.amazonaws.com\",\"requestId\":\"de2cRil5liAEM5Q=\",\"accountId\":\"486652066693\"},\"rawQueryString\":\"arg1=value1&arg2=value2\",\"rawPath\":\"\\\/test\"}","statusCode":202}% +sam local invoke -t sam.json -e Tests/LambdaTests/data/apiv2.json HttpApiLambda ``` ### Command Line Options From 971af4f34120cdfe66902b329637b531f564cb31 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 10:19:44 +0100 Subject: [PATCH 32/79] minor refactor and reformat --- Plugins/AWSLambdaDeployer/Plugin.swift | 6 +++--- .../DeploymentDefinition.swift | 18 +++++++++--------- .../DeploymentDescriptor.swift | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 9d52ccce..348daeea 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -85,7 +85,7 @@ struct AWSLambdaPackager: CommandPlugin { print("`Deploy.Swift` file not found in directory \(projectDirectory)") throw DeployerPluginError.deployswiftDoesNotExist } - + do { let cmd = [ swiftExecutable.string, @@ -198,7 +198,7 @@ struct AWSLambdaPackager: CommandPlugin { print("Deploying AWS Lambda function") print("-------------------------------------------------------------------------") do { - + try Utils.execute( executable: samExecutablePath, arguments: ["deploy", @@ -272,7 +272,7 @@ private struct Configuration: CustomStringConvertible { let archiveDirectoryArgument = argumentExtractor.extractOption(named: "archive-path") let stackNameArgument = argumentExtractor.extractOption(named: "stackname") let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 - + // help required ? self.help = helpArgument diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index 765bc208..cbff3304 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -5,7 +5,7 @@ import Foundation // public struct DeploymentDefinition { - private var deployment : DeploymentDescriptor? = nil // optional is required to allow initialization after the capturing closure (compactMap) + private var deployment : DeploymentDescriptor public init ( // the description of the SAM template @@ -18,7 +18,7 @@ public struct DeploymentDefinition { resources: [Resource]) { - let functionResources = createResources(for: functions) + let functionResources = DeploymentDefinition.createResources(for: functions) self.deployment = SAMDeployment(description: description, resources: functionResources + resources) @@ -29,7 +29,7 @@ public struct DeploymentDefinition { } // create one Resource per function + additional resource for the function dependencies (ex: a SQS queue) - private func createResources(for functions: [Function]) -> [Resource] { + private static func createResources(for functions: [Function]) -> [Resource] { var additionalresources : [Resource] = [] let functionResources = functions.compactMap { function in @@ -43,7 +43,7 @@ public struct DeploymentDefinition { lambdaPackage = "\(archiveArg)/\(function.name)/\(function.name).zip" } } - + // check the ZIP file exists if !FileManager.default.fileExists(atPath: lambdaPackage!) { // I choose to report an error in the generated JSON. @@ -57,9 +57,9 @@ public struct DeploymentDefinition { // TODO: can we add code in `Plugin.swift` to force it to fail when such error is detected lambdaPackage = "### ERROR package does not exist: \(lambdaPackage!) ###" } - + // extract sqs resources to be created, if any - additionalresources += self.explicitQueueResources(function: function) + additionalresources += DeploymentDefinition.explicitQueueResources(function: function) return Resource.serverlessFunction(name: function.name, architecture: function.architecture, @@ -77,7 +77,7 @@ public struct DeploymentDefinition { // the event source eventually creates the queue resource and it 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 synthetize - private func explicitQueueResources(function: Function) -> [Resource] { + private static func explicitQueueResources(function: Function) -> [Resource] { return function.eventSources // first filter on event sources of type SQS where the `queue` property is defined (not nil) @@ -93,10 +93,10 @@ public struct DeploymentDefinition { if pretty { encoder.outputFormatting = .prettyPrinted } - let jsonData = try! encoder.encode(self.deployment!) + let jsonData = try! encoder.encode(self.deployment) return String(data: jsonData, encoding: .utf8)! } - + } // Intermediate structure to generate SAM Resources of type AWS::Serverless::Function diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index f997c2f9..ef4296fb 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -62,7 +62,7 @@ public struct Resource: SAMResource, Equatable { let type: String let properties: SAMResourceProperties let name: String - + public static func == (lhs: Resource, rhs: Resource) -> Bool { lhs.type == rhs.type && lhs.name == rhs.name } From 9031b6ae1768fd2ea0950a7c1f8d238d96701f83 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 10:20:56 +0100 Subject: [PATCH 33/79] minor refactor --- .../AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift index cbff3304..0570db85 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift @@ -5,7 +5,7 @@ import Foundation // public struct DeploymentDefinition { - private var deployment : DeploymentDescriptor + private let deployment : DeploymentDescriptor public init ( // the description of the SAM template From 6ed5f150c36fcab173a5f6c0737cb7f66228eb4b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 1 Feb 2023 13:22:16 +0100 Subject: [PATCH 34/79] more code cleanup and documentation --- Examples/SAM/HttpApiLambda/Lambda.swift | 2 +- Examples/SAM/Package.swift | 21 ++++++++++--------- Plugins/AWSLambdaDeployer/README.md | 20 ++++++++++++++++-- Plugins/AWSLambdaPackager/Plugin.swift | 2 +- .../DeploymentDescriptor.swift | 14 +++---------- .../DeploymentDescriptorTests.swift | 13 +----------- 6 files changed, 35 insertions(+), 37 deletions(-) diff --git a/Examples/SAM/HttpApiLambda/Lambda.swift b/Examples/SAM/HttpApiLambda/Lambda.swift index dfbf8710..9baabba1 100644 --- a/Examples/SAM/HttpApiLambda/Lambda.swift +++ b/Examples/SAM/HttpApiLambda/Lambda.swift @@ -43,7 +43,7 @@ struct HttpApiLambda: SimpleLambdaHandler { } catch { // should never happen as the decoding was made by the runtime - // when the inoput event is malformed, this function is not even called + // when the input event is malformed, this function is not even called header["content-type"] = "text/plain" return Output(statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") } diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index 745f1b85..e719f5c2 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -28,10 +28,8 @@ let package = Package( dependencies: [ // this is the dependency on the swift-aws-lambda-runtime library // in real-world projects this would say -// .package(url: "https://github.com/sebsto/swift-aws-lambda-runtime.git", branch: "sebsto/deployerplugin"), -// .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), + // .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), .package(name: "swift-aws-lambda-runtime", path: "../.."), -// .package(url: "../..", branch: "sebsto/deployerplugin"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") ], targets: [ @@ -40,9 +38,6 @@ let package = Package( dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - // linking the dynamic library does not work on Linux with --static-swift-stdlib because it is a dynamic library, - // but we don't need it for packaging. Making it conditional will generate smaller ZIP files - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) ], path: "./HttpApiLambda" ), @@ -51,9 +46,6 @@ let package = Package( dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - // linking the dynamic library does not work on Linux with --static-swift-stdlib because it is a dynamic library, - // but we don't need it for packaging. Making it conditional will generate smaller ZIP files - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) ] , path: "./SQSLambda" ), @@ -61,8 +53,17 @@ let package = Package( name: "LambdaTests", dependencies: [ "HttpApiLambda", "SQSLambda", - .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime") + .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), + + // at least one target must have this dependency. It ensures the dylib is built + // The dylib is loaded dynamically by the deploy plugin when compiling Deploy.swift. + // The Lambda functions or the tests do not actually depend on it. + // I choose to add the dependency on a test target, because the archive plugin + // links the executable targets with --static-swift-stdlib which cannot include this dynamic library, + // and causes the build to fail + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") ], + // testing data resources: [ .process("data/apiv2.json"), .process("data/sqs.json") diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index bf13ea2a..dec76a71 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -94,7 +94,23 @@ let _ = DeploymentDefinition( ) ``` -3. I invoke the archive plugin and the deploy plugin from the command line. +3. I add a dependency in my project's `Package.swift`. On a `testTarget`, I add this dependency: + +```swift + // on the testTarget() + dependencies: [ + // other dependencies + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") + ] +``` + +I also might add this dependency on one of your Lambda function `executableTarget`. In this case, I make sure it is added only when building on macOS. + +```swift + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) +``` + +4. I invoke the archive plugin and the deploy plugin from the command line. ```bash @@ -109,7 +125,7 @@ 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. +5. (optionally) Swift lambda function developer may also use SAM to test the code locally. ```bash sam local invoke -t sam.json -e Tests/LambdaTests/data/apiv2.json HttpApiLambda diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index bce9f588..dae18f62 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -254,7 +254,7 @@ private struct Configuration: CustomStringConvertible { self.products = products } else { - self.products = context.package.products.filter { $0 is ExecutableProduct && $0.name != "Deploy" } + self.products = context.package.products.filter { $0 is ExecutableProduct } } if let buildConfigurationName = configurationArgument.first { diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index ef4296fb..660cd1c4 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -112,7 +112,7 @@ public enum Architectures: String, Encodable, CaseIterable { public static func defaultArchitecture() -> Architectures { #if arch(arm64) return .arm64 -#else +#else // I understand this #else will not always be true. Developers can overwrite the default in Deploy.swift return .x64 #endif } @@ -291,9 +291,7 @@ public struct EventSource: SAMEvent { public static func == (lhs: EventSource, rhs: EventSource) -> Bool { lhs.type == rhs.type && lhs.name == rhs.name } - - public static func none() -> [EventSource] { return [] } - + // this is to make the compiler happy : Resource now confoms to Encodable public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -548,10 +546,4 @@ extension Resource { let noHyphenName = noSpaceName.split(separator: "-").map{ $0.capitalized }.joined(separator: "") return resourceType.capitalized + noHyphenName } -} - -public enum DeploymentEncodingError: Error { - case yamlError(causedBy: Error) - case jsonError(causedBy: Error) - case stringError(causedBy: Data) -} +} \ No newline at end of file diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 3ca5af40..37ba5b43 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -17,21 +17,10 @@ import XCTest final class DeploymentDescriptorTest: XCTestCase { - var originalCommandLineArgs: [String] = [] - - override func setUp() { - // save the original Command Line args, just in case - originalCommandLineArgs = CommandLine.arguments - CommandLine.arguments = ["mocked_arg0", "TestLambda"] - } - override func tearDown() { - CommandLine.arguments = originalCommandLineArgs - } - private func generateAndTestDeploymentDecsriptor(deployment: MockDeploymentDescriptor, expected: String) -> Bool { // when let samJSON = deployment.deploymentDescriptor.toJSON(pretty: false) - print(samJSON) + // then return samJSON.contains(expected) } From c2d80723eeb89b9503da748109f5e6ecd08498c7 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 16 Feb 2023 09:52:49 +0100 Subject: [PATCH 35/79] use sam list command to print endpoints and improve error handling when there is no change to deploy --- Plugins/AWSLambdaDeployer/Plugin.swift | 52 +++++++++++++++++++++ Plugins/AWSLambdaPackager/PluginUtils.swift | 6 +-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 348daeea..b1a16580 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -60,6 +60,15 @@ struct AWSLambdaPackager: CommandPlugin { stackName : configuration.stackName, 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 generateDeploymentDescriptor(projectDirectory: Path, @@ -212,12 +221,48 @@ struct AWSLambdaPackager: CommandPlugin { 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 { + + //TODO: check if there is a samconfig.toml file. + // when there is no file, generate one with default data or data collected from params + + + 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) + } + } + private func displayHelpMessage() { print(""" OVERVIEW: A swift plugin to deploy your Lambda function on your AWS account. @@ -242,6 +287,7 @@ OPTIONS: --stack-name The name of the CloudFormation stack when deploying. (default: the project name) + --nolist Do not list endpoints --help Show help information. """) } @@ -251,6 +297,7 @@ private struct Configuration: CustomStringConvertible { public let buildConfiguration: PackageManager.BuildConfiguration public let help: Bool public let noDeploy: Bool + public let noList: Bool public let verboseLogging: Bool public let archiveDirectory: String public let stackName: String @@ -268,6 +315,7 @@ private struct Configuration: CustomStringConvertible { 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 configurationArgument = argumentExtractor.extractOption(named: "configuration") let archiveDirectoryArgument = argumentExtractor.extractOption(named: "archive-path") let stackNameArgument = argumentExtractor.extractOption(named: "stackname") @@ -278,6 +326,9 @@ private struct Configuration: CustomStringConvertible { // define deployment option self.noDeploy = nodeployArgument + + // define control on list endpoints after a deployment + self.noList = noListArgument // define logging verbosity self.verboseLogging = verboseArgument @@ -327,6 +378,7 @@ private struct Configuration: CustomStringConvertible { { verbose: \(self.verboseLogging) noDeploy: \(self.noDeploy) + noList: \(self.noList) buildConfiguration: \(self.buildConfiguration) archiveDirectory: \(self.archiveDirectory) stackName: \(self.stackName) diff --git a/Plugins/AWSLambdaPackager/PluginUtils.swift b/Plugins/AWSLambdaPackager/PluginUtils.swift index 810bd21f..b6de81ca 100644 --- a/Plugins/AWSLambdaPackager/PluginUtils.swift +++ b/Plugins/AWSLambdaPackager/PluginUtils.swift @@ -82,18 +82,18 @@ struct Utils { print(output) fflush(stdout) } - throw ProcessError.processFailed([executable.string] + arguments, process.terminationStatus) + throw ProcessError.processFailed([executable.string] + arguments, process.terminationStatus, output) } return output } enum ProcessError: Error, CustomStringConvertible { - case processFailed([String], Int32) + case processFailed([String], Int32, String) var description: String { switch self { - case .processFailed(let arguments, let code): + case .processFailed(let arguments, let code, _): return "\(arguments.joined(separator: " ")) failed with code \(code)" } } From 46b53ed8492254f652b205587a28f4314c689a41 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 16 Feb 2023 11:11:23 +0100 Subject: [PATCH 36/79] simplify example code --- Examples/SAM/HttpApiLambda/Lambda.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Examples/SAM/HttpApiLambda/Lambda.swift b/Examples/SAM/HttpApiLambda/Lambda.swift index 9baabba1..463662f9 100644 --- a/Examples/SAM/HttpApiLambda/Lambda.swift +++ b/Examples/SAM/HttpApiLambda/Lambda.swift @@ -17,17 +17,15 @@ import AWSLambdaRuntime import Foundation @main -struct HttpApiLambda: SimpleLambdaHandler { - typealias Event = APIGatewayV2Request - typealias Output = APIGatewayV2Response - +struct HttpApiLambda: SimpleLambdaHandler { 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 { + + // 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 { @@ -39,13 +37,16 @@ struct HttpApiLambda: SimpleLambdaHandler { let data = try JSONEncoder().encode(event) let response = String(data: data, encoding: .utf8) - return Output(statusCode: .ok, headers: header, body: response) + // if you want contronl 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 Output(statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") + return APIGatewayV2Response(statusCode: .badRequest, headers: header, body: "\(error.localizedDescription)") + } } } From 1f054b03e88c5b1e5c42ff007b02375adcfc0ec3 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Fri, 17 Feb 2023 15:01:50 +0100 Subject: [PATCH 37/79] add extra sam commands --- Examples/SAM/Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile index dcb2cabd..616df9b3 100644 --- a/Examples/SAM/Makefile +++ b/Examples/SAM/Makefile @@ -19,6 +19,12 @@ deploy: build nodeploy: build swift package --disable-sandbox deploy --verbose --nodeploy --configuration debug +localtest: + sam local invoke -t sam.json -e Tests/LambdaTests/data/apiv2.json HttpApiLambda + +list: + sam list endpoints -t sam.json --stack-name swift-aws-lambda-runtime-example --output json + clean: # find . -name .build -exec rm -rf {} \; # find . -name .swiftpm -exec rm -rf {} \; From e4401a4b3ff948c8c4c711f86acd38570056ed2e Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Fri, 17 Feb 2023 15:41:23 +0100 Subject: [PATCH 38/79] Update the README file --- Plugins/AWSLambdaDeployer/README.md | 61 +++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index dec76a71..57365268 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -1,6 +1,6 @@ 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: +## 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. @@ -14,11 +14,7 @@ The `deploy` plugin leverages SAM to create an end-to-end infrastructure and to The Lambda function developer describes the API gateway or SQS queue using the Swift programming language by writing a `Deploy.swift` file (similar to `Package.swift` used by SPM). The plugin transform the `Deploy.swift` data structure into a SAM template. It then calls the SAM CLI to validate and to deploy the template. -### Why create a dependency on 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 ... I am curious to get community feedback about this choice. - -### Modifications: +## Modifications: I added two targets to `Package.swift` : @@ -28,7 +24,7 @@ I added two targets to `Package.swift` : I 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: +## Result: As a Swift function developer, here is the workflow to use the new `deploy` plugin. @@ -104,7 +100,7 @@ let _ = DeploymentDefinition( ] ``` -I also might add this dependency on one of your Lambda function `executableTarget`. In this case, I make sure it is added only when building on macOS. +I also might add this dependency on one of my Lambda functions `executableTarget`. In this case, I make sure it is added only when building on macOS. ```swift .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) @@ -131,7 +127,7 @@ Similarly to the archiver plugin, the deployer plugin must escape the sandbox be sam local invoke -t sam.json -e Tests/LambdaTests/data/apiv2.json HttpApiLambda ``` -### Command Line Options +## Command Line Options The deployer plugin accepts multiple options on the command line. @@ -148,6 +144,7 @@ USAGE: swift package --disable-sandbox deploy [--help] [--verbose] [--nodeploy] OPTIONS: --verbose Produce verbose output for debugging. --nodeploy Generates the JSON deployment descriptor, but do not deploy. + --nolist Do not call sam list to list endpoints --configuration Build for a specific configuration. Must be aligned with what was used to build and package. @@ -162,11 +159,51 @@ OPTIONS: --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 + +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 struct in a top-level `Deploy.swift` file. The `deploy` plugin dynamically compiles this file to generate the SAM JSON deployment descriptor. + +The source code to implement this approach is in the `AWSLambdaDeploymentDescriptor` library. This approach is similar to `Package.swift` used by the Swift Package Manager. + +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. + +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** + +One alternative proposed during early reviews is to use a DSL instead of a programmatic approach. A Swift-based DSL might be implemented using [Result Builders](https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md), available since Swift 5.4. +After having read [tutorials](https://theswiftdev.com/result-builders-in-swift/) and watch a [WWDC 2021 session](https://developer.apple.com/videos/play/wwdc2021/10253/), I understand this might provide Swift Lambda function developer a strongly typed, Swift-friendly, DSL to express their SAM deployment descriptor. I will give a try to this approach and propose it in a PR. + +Another alternative is to not use a programmatic 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. -### What is missing ? +- 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. -If this proposal is accepted, 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) +- 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. -Just like for the `archive` plugin, it would be great to have a more granular permission mechanism allowing to escape the plugin sandbox for selected network calls. +--- Happy to read your feedback and suggestions. Let's make the deployment of Swift Lambda functions easier for Swift developers. From c39b82d2f7df85ab0eef542a02f481df2f547ba4 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Fri, 17 Feb 2023 15:42:55 +0100 Subject: [PATCH 39/79] update git ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 04708b0f..000f5669 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ xcuserdata Package.resolved .serverless +.vscode From 9e6e222d58bdbdf63aa43d2958b90f6b34e93aa2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sun, 26 Feb 2023 08:32:25 +0100 Subject: [PATCH 40/79] fix typos in comments --- Examples/SAM/Package.swift | 2 +- Plugins/AWSLambdaDeployer/Plugin.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index e719f5c2..be46ea3d 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -55,7 +55,7 @@ let package = Package( "HttpApiLambda", "SQSLambda", .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), - // at least one target must have this dependency. It ensures the dylib is built + // at least one target must have this dependency. It ensures Swift builds the dylib // The dylib is loaded dynamically by the deploy plugin when compiling Deploy.swift. // The Lambda functions or the tests do not actually depend on it. // I choose to add the dependency on a test target, because the archive plugin diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index b1a16580..804d9702 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -83,7 +83,7 @@ struct AWSLambdaPackager: CommandPlugin { // // Build and run the Deploy.swift package description - // this generates the SAM deployment decsriptor + // this generates the SAM deployment descriptor // let deploymentDescriptorFileName = "Deploy.swift" let deploymentDescriptorFilePath = "\(projectDirectory)/\(deploymentDescriptorFileName)" From e5de04b671b8412174d386a4a1c62a30616e4508 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sun, 26 Feb 2023 20:12:15 +0100 Subject: [PATCH 41/79] first version of DSL support to express the SAM Deployment Descriptor --- Examples/SAM/Deploy.swift | 146 +-- Package.swift | 4 + .../DeploymentDefinition.swift | 150 --- .../DeploymentDescriptor.swift | 879 +++++++++--------- .../DeploymentDescriptorBuilder.swift | 385 ++++++++ 5 files changed, 911 insertions(+), 653 deletions(-) delete mode 100644 Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift create mode 100644 Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift index 94479ade..b8abbeab 100644 --- a/Examples/SAM/Deploy.swift +++ b/Examples/SAM/Deploy.swift @@ -1,71 +1,85 @@ import AWSLambdaDeploymentDescriptor // example of a shared resource -let sharedQueue : Resource = Resource.queue(logicalName: "SharedQueue", - physicalName: "swift-lambda-shared-queue") +let sharedQueue = Queue( + logicalName: "SharedQueue", + physicalName: "swift-lambda-shared-queue") // example of common environment variables -let sharedEnvironementVariables = ["LOG_LEVEL":"debug"] - -let _ = DeploymentDefinition( - - description: "Working SAM template for Swift Lambda function", - - functions: [ - - .function( - // the name of the function - name: "HttpApiLambda", - - // the AWS Lambda architecture (defaults to current build platform) - //architecture: .x64, - - // the event sources - eventSources: [ - // example of a catch all API -// .httpApi(), - - // example of an API for a specific path and specific http verb - .httpApi(method: .GET, path: "/test"), - ], - - // optional environment variables - one variable - //environment: .variable("NAME","VALUE") - - // optional environment variables - multiple environment variables at once - environment: .variable([sharedEnvironementVariables, ["NAME2":"VALUE2"]]) - ), - - .function( - name: "SQSLambda", - eventSources: [ - // this will reference an existing queue -// .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:swift-lambda-shared-queue"), - - // this will create a new queue resource - .sqs(queue: "swift-lambda-queue-name"), - - // this will create a new queue resource -// .sqs(queue: .queue(logicalName: "LambdaQueueResource", physicalName: "swift-lambda-queue-resource")) - - // this will reference a queue resource created in this deployment descriptor -// .sqs(queue: sharedQueue) - ], - environment: .variable(sharedEnvironementVariables) - ) - ], - - // additional resources - 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") - ] -) +let sharedEnvironmentVariables = ["LOG_LEVEL": "debug"] + +// the deployment descriptor +DeploymentDescriptor { + + // a mandatory description + "Description of this deployment descriptor" + + // a lambda function + Function(name: "HttpApiLambda", architecture: .x64) { + + 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 + } + } + + // create a Lambda function and its depending resources + Function(name: "SQSLambda") { + + 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 will reference a shared queue resource created in this deployment descriptor + // Sqs(sharedQueue) + } + + EnvironmentVariables { + + sharedEnvironmentVariables + + } + } + + // shared resources declared upfront + sharedQueue + + // + // additional resources + // + // create a SAS 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") + +} diff --git a/Package.swift b/Package.swift index 7a3277fd..d0a9b763 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.4.2")), .package(url: "https://github.com/swift-server/swift-backtrace.git", .upToNextMajor(from: "1.2.3")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/jpsim/Yams.git", .upToNextMajor(from: "5.0.5")), ], targets: [ .target(name: "AWSLambdaRuntime", dependencies: [ @@ -55,6 +56,9 @@ let package = Package( ), .target( name: "AWSLambdaDeploymentDescriptor", + dependencies: [ + .product(name: "Yams", package: "Yams") + ], path: "Sources/AWSLambdaDeploymentDescriptor" ), .plugin( diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift deleted file mode 100644 index 0570db85..00000000 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDefinition.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation - -// -// The deployment definition -// -public struct DeploymentDefinition { - - private let deployment : DeploymentDescriptor - - public init ( - // the description of the SAM template - description: String, - - // a list of AWS Lambda functions - functions: [Function], - - // a list of additional AWS resources to create - resources: [Resource]) - { - - let functionResources = DeploymentDefinition.createResources(for: functions) - - self.deployment = SAMDeployment(description: description, - resources: functionResources + resources) - - //TODO: add default output section to return the URL of the API Gateway - - dumpPackageAtExit(self, to: 1) // 1 = stdout - } - - // create one Resource per function + additional resource for the function dependencies (ex: a SQS queue) - private static func createResources(for functions: [Function]) -> [Resource] { - - var additionalresources : [Resource] = [] - let functionResources = functions.compactMap { function in - - // compute the path for the lambda archive - // propose a default path unless the --archive-path argument was used - var lambdaPackage : String? = ".build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/\(function.name)/\(function.name).zip" - if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { - if CommandLine.arguments.count >= optIdx + 1 { - let archiveArg = CommandLine.arguments[optIdx + 1] - lambdaPackage = "\(archiveArg)/\(function.name)/\(function.name).zip" - } - } - - // check the ZIP file exists - if !FileManager.default.fileExists(atPath: lambdaPackage!) { - // I choose to report an error in the generated JSON. - // happy to listen other ideas. - // I think they are 5 options here - // 1. fatalError() -> does not allow for unit testing - // 2. throw Error -> requires Lambda function developer to manage the error - // 3. return an empty deployement descriptor {} => would require more changes in DeploymentDescriptor - // 4. return an error message to be reported in CodeUri property => but `sam validate` does not catch it. - // 5. return nil and CodeUri will be ignored in the JSON => but `sam validate` does not catch it. - // TODO: can we add code in `Plugin.swift` to force it to fail when such error is detected - lambdaPackage = "### ERROR package does not exist: \(lambdaPackage!) ###" - } - - // extract sqs resources to be created, if any - additionalresources += DeploymentDefinition.explicitQueueResources(function: function) - - return Resource.serverlessFunction(name: function.name, - architecture: function.architecture, - codeUri: lambdaPackage, - eventSources: function.eventSources, - environment: function.environment) - } - return functionResources + additionalresources - } - - // 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 eventually creates the queue resource and it 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 synthetize - private static func explicitQueueResources(function: Function) -> [Resource] { - - return function.eventSources - // first filter on event sources of type SQS where the `queue` property is defined (not nil) - .filter{ lambdaEventSource in - lambdaEventSource.type == .sqs && (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil } - // next extract the resource part of the sqsEventSource - .compactMap { - sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue } - } - - public func toJSON(pretty: Bool = true) -> String { - let encoder = JSONEncoder() - if pretty { - encoder.outputFormatting = .prettyPrinted - } - let jsonData = try! encoder.encode(self.deployment) - return String(data: jsonData, encoding: .utf8)! - } - -} - -// Intermediate structure to generate SAM Resources of type AWS::Serverless::Function -// this struct allows function developers to not provide the CodeUri property in Deploy.swift -// CodeUri is added when the SAM template is generated -// it is also a place to perform additional sanity checks - -//TODO: should I move this to the Resource() struct ? Then I need a way to add CodeUri at a later stage -public struct Function { - let name: String - let architecture: Architectures - let eventSources: [EventSource] - let environment: EnvironmentVariable - public static func function(name : String, - architecture: Architectures = Architectures.defaultArchitecture(), - eventSources: [EventSource], - environment: EnvironmentVariable = .none) -> Function { - - //TODO: report an error when multiple event sources of the same type are given - //but print() is not sent to stdout when invoked from the plugin 🤷‍♂️ - - // guardrail to avoid misformed SAM template - if eventSources.filter({ source in source.type == .sqs }).count > 1 || - eventSources.filter({ source in source.type == .httpApi }).count > 1 { - fatalError("WARNING - Function \(name) can only have one event source of each type") - } - - return self.init(name: name, - architecture: architecture, - eventSources: eventSources, - environment: environment) - } -} - -// inspired by -// https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/PackageDescription.swift#L479 -// left public for testing -private func manifestToJSON(_ deploymentDefinition : DeploymentDefinition) -> String { - return deploymentDefinition.toJSON() -} -private var dumpInfo: (deploymentDefinition: DeploymentDefinition, fileDesc: Int32)? -private func dumpPackageAtExit(_ deploymentDefinition: DeploymentDefinition, to fileDesc: Int32) { - func dump() { - guard let dumpInfo = dumpInfo else { return } - guard let fd = fdopen(dumpInfo.fileDesc, "w") else { return } - fputs(manifestToJSON(dumpInfo.deploymentDefinition), fd) - fclose(fd) - } - dumpInfo = (deploymentDefinition, fileDesc) - atexit(dump) -} diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 660cd1c4..d2840fe1 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -14,167 +14,178 @@ import Foundation -public protocol DeploymentDescriptor: Encodable {} - // 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 decsriptor. +// 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 classes -public struct SAMDeployment: DeploymentDescriptor { - - let awsTemplateFormatVersion: String - let transform: String - let description: String - var resources: [String: Resource] - - public init( - templateVersion: String = "2010-09-09", - transform: String = "AWS::Serverless-2016-10-31", - description: String = "A SAM template to deploy a Swift Lambda function", - resources: [Resource] = [] - ) { - - self.awsTemplateFormatVersion = templateVersion - self.transform = transform - self.description = description - self.resources = [String: Resource]() - - for res in resources { - self.resources[res.name] = res - } - } - - enum CodingKeys: String, CodingKey { - case awsTemplateFormatVersion = "AWSTemplateFormatVersion" - case transform = "Transform" - case description = "Description" - case resources = "Resources" - } +// 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 = "A SAM template to deploy a Swift Lambda function", + resources: [Resource] = [] + ) { + self.description = description + for res in resources { + self.resources[res.name] = res + } + } + + enum CodingKeys: String, CodingKey { + case templateVersion = "AWSTemplateFormatVersion" + case transform = "Transform" + case description = "Description" + case resources = "Resources" + } } public protocol SAMResource: Encodable {} +public protocol SAMResourceType: Encodable, Equatable {} public protocol SAMResourceProperties: Encodable {} -public struct Resource: SAMResource, Equatable { - - let type: String - let properties: SAMResourceProperties - let name: String - - public static func == (lhs: Resource, rhs: Resource) -> Bool { - lhs.type == rhs.type && lhs.name == rhs.name - } - - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" - } - - // this is to make the compiler happy : Resource now confoms to Encodable - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try? container.encode(self.type, forKey: .type) - try? container.encode(self.properties, forKey: .properties) - } +public enum ResourceType: String, SAMResourceType { + case serverlessFunction = "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, Equatable { + + 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: String, CodingKey { + case type = "Type" + case properties = "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 -----------------------------------------------------------------------------------------*/ extension Resource { - public static func serverlessFunction(name: String, - architecture: Architectures, - codeUri: String?, - eventSources: [EventSource] = [], - environment: EnvironmentVariable = .none) -> Resource { - - let properties = ServerlessFunctionProperties(codeUri: codeUri, - architecture: architecture, - eventSources: eventSources, - environment: environment) - return Resource(type: "AWS::Serverless::Function", - properties: properties, - name: name) - } + public static func serverlessFunction( + name: String, + architecture: Architectures, + codeUri: String?, + eventSources: [Resource] = [], + environment: SAMEnvironmentVariable = .none + ) -> Resource { + + let properties = ServerlessFunctionProperties( + codeUri: codeUri, + architecture: architecture, + eventSources: eventSources, + environment: environment) + return Resource( + type: .serverlessFunction, + properties: properties, + name: name) + } } 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: ", ") - } + 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: ", ") + } } public struct ServerlessFunctionProperties: SAMResourceProperties { - let architectures: [Architectures] - let handler: String - let runtime: String - let codeUri: String? - let autoPublishAlias: String - var eventSources: [String: EventSource] - var environment: EnvironmentVariable - - public init(codeUri: String?, - architecture: Architectures, - eventSources: [EventSource] = [], - environment: EnvironmentVariable = .none) { - - self.architectures = [architecture] - self.handler = "Provided" - self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 - self.autoPublishAlias = "Live" - self.codeUri = codeUri - self.eventSources = [String: EventSource]() - self.environment = environment - - for es in eventSources { - self.eventSources[es.name] = es - } - } - - // custom encoding to not provide Environment variables when there is none - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.architectures, forKey: .architectures) - try container.encode(self.handler, forKey: .handler) - try container.encode(self.runtime, forKey: .runtime) - if let codeUri = self.codeUri { - try container.encode(codeUri, forKey: .codeUri) - } - try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) - try container.encode(self.eventSources, forKey: .eventSources) - if !environment.isEmpty() { - try container.encode(self.environment, forKey: .environment) - } - } - - enum CodingKeys: String, CodingKey { - case architectures = "Architectures" - case handler = "Handler" - case runtime = "Runtime" - case codeUri = "CodeUri" - case autoPublishAlias = "AutoPublishAlias" - case eventSources = "Events" - case environment = "Environment" + let architectures: [Architectures] + let handler: String + let runtime: String + let codeUri: String? + let autoPublishAlias: String + var eventSources: [String: Resource] + var environment: SAMEnvironmentVariable + + public init( + codeUri: String?, + architecture: Architectures, + eventSources: [Resource] = [], + environment: SAMEnvironmentVariable = .none + ) { + + self.architectures = [architecture] + self.handler = "Provided" + self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 + self.autoPublishAlias = "Live" + self.codeUri = codeUri + self.eventSources = [:] + self.environment = environment + + for es in eventSources { + self.eventSources[es.name] = es } + } + + // custom encoding to not provide Environment variables when there is none + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.architectures, forKey: .architectures) + try container.encode(self.handler, forKey: .handler) + try container.encode(self.runtime, forKey: .runtime) + if let codeUri = self.codeUri { + try container.encode(codeUri, forKey: .codeUri) + } + try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) + try container.encode(self.eventSources, forKey: .eventSources) + if !environment.isEmpty() { + try container.encode(self.environment, forKey: .environment) + } + } + + enum CodingKeys: String, CodingKey { + case architectures = "Architectures" + case handler = "Handler" + case runtime = "Runtime" + case codeUri = "CodeUri" + case autoPublishAlias = "AutoPublishAlias" + case eventSources = "Events" + case environment = "Environment" + } } /* @@ -182,368 +193,362 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { Variables: LOG_LEVEL: debug */ -public struct EnvironmentVariable: Encodable { - - public var variables: [String:EnvironmentVariableValue] = [:] - public init() {} - public init(_ variables: [String:String]) { - for key in variables.keys { - self.variables[key] = .string(value: variables[key] ?? "") - } - } - public static var none : EnvironmentVariable { return EnvironmentVariable([:]) } - - public static func variable(_ name: String, _ value: String) -> EnvironmentVariable { return EnvironmentVariable([name: value]) } - public static func variable(_ variables: [String:String]) -> EnvironmentVariable { return EnvironmentVariable(variables) } - public static func variable(_ variables: [[String:String]]) -> EnvironmentVariable { - - var mergedDictKeepCurrent : [String:String] = [:] - variables.forEach { dict in - // inspired by https://stackoverflow.com/a/43615143/663360 - mergedDictKeepCurrent = mergedDictKeepCurrent.merging(dict) { (current, _) in current } - } - - return EnvironmentVariable(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: String, CodingKey { - case variables = "Variables" - } - - public func encode(to encoder: Encoder) throws { - 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 EnvironmentVariableValue { - // KEY: VALUE - case string(value: String) - - // KEY: - // Ref: VALUE - case array(value: [String:String]) - - // KEY: - // Fn::GetAtt: - // - VALUE1 - // - VALUE2 - case dictionary(value: [String:[String]]) - } - - private 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) } - } -} +public struct SAMEnvironmentVariable: Encodable { -//MARK: Lambda Function event source + 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 protocol SAMEvent : Encodable, Equatable {} -public protocol SAMEventProperties : Encodable {} + 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 { -public enum EventSourceType: String, Encodable { - case httpApi = "HttpApi" - case sqs = "SQS" -} -public struct EventSource: SAMEvent { - - let type: EventSourceType - let properties: SAMEventProperties? - let name: String - - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" - } - - public static func == (lhs: EventSource, rhs: EventSource) -> Bool { - lhs.type == rhs.type && lhs.name == rhs.name + var mergedDictKeepCurrent: [String: String] = [:] + variables.forEach { dict in + // inspired by https://stackoverflow.com/a/43615143/663360 + mergedDictKeepCurrent = mergedDictKeepCurrent.merging(dict) { (current, _) in current } } - - // this is to make the compiler happy : Resource now confoms 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) - } - } -} + 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: String, CodingKey { + case variables = "Variables" + } + + public func encode(to encoder: Encoder) throws { + 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]]) + } + + private 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 -----------------------------------------------------------------------------------------*/ -extension EventSource { - public static func httpApi(name: String = "HttpApiEvent", - method: HttpVerb? = nil, - path: String? = nil) -> EventSource { - - var properties: SAMEventProperties? = nil - if method != nil || path != nil { - properties = HttpApiProperties(method: method, path: path) - } - - return EventSource(type: .httpApi, - properties: properties, - name: name) +extension Resource { + public static func httpApi( + name: String = "HttpApiEvent", + method: HttpVerb? = nil, + path: String? = nil + ) -> Resource { + + var properties: SAMResourceProperties? = nil + if method != nil || path != nil { + properties = HttpApiProperties(method: method, path: path) } + + return Resource( + type: .httpApi, + properties: properties, + name: name) + } } -struct HttpApiProperties: SAMEventProperties, Equatable { - init(method: HttpVerb? = nil, path: String? = nil) { - self.method = method - self.path = path - } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: HttpApiKeys.self) - if let method = self.method { - try container.encode(method, forKey: .method) - } - if let path = self.path { - try container.encode(path, forKey: .path) - } - } - let method: HttpVerb? - let path: String? - - enum HttpApiKeys: String, CodingKey { - case method = "Method" - case path = "Path" - } +struct HttpApiProperties: SAMResourceProperties, Equatable { + init(method: HttpVerb? = nil, path: String? = nil) { + self.method = method + self.path = path + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: HttpApiKeys.self) + if let method = self.method { + try container.encode(method, forKey: .method) + } + if let path = self.path { + try container.encode(path, forKey: .path) + } + } + let method: HttpVerb? + let path: String? + + enum HttpApiKeys: String, CodingKey { + case method = "Method" + case path = "Path" + } } public enum HttpVerb: String, Encodable { - case GET - case POST + case GET + case POST } //MARK: SQS event definition /*--------------------------------------------------------------------------------------- SQS Event - + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html -----------------------------------------------------------------------------------------*/ -extension EventSource { - private static func sqs(name: String = "SQSEvent", - properties: SQSEventProperties) -> EventSource { - - return EventSource(type: .sqs, - properties: properties, - name: name) - } - public static func sqs(name: String = "SQSEvent", - queue queueRef: String) -> EventSource { - - let properties = SQSEventProperties(byRef: queueRef) - return EventSource.sqs(name: name, - properties: properties) - } - - public static func sqs(name: String = "SQSEvent", - queue: Resource) -> EventSource { - - let properties = SQSEventProperties(queue) - return EventSource.sqs(name: name, - properties: properties) - } +extension Resource { + internal static func sqs( + name: String = "SQSEvent", + properties: SQSEventProperties + ) -> Resource { + + return Resource( + type: .sqs, + properties: properties, + name: name) + } + public static func sqs( + name: String = "SQSEvent", + queue queueRef: String + ) -> Resource { + + let properties = SQSEventProperties(byRef: queueRef) + return Resource.sqs( + name: name, + properties: properties) + } + + public static func sqs( + name: String = "SQSEvent", + queue: Resource + ) -> Resource { + + let properties = SQSEventProperties(queue) + return Resource.sqs( + name: name, + properties: properties) + } } -/** - 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: SAMEventProperties, Equatable { - - public var queueByArn: String? = nil - public var queue: Resource? = nil - - init(byRef ref: String) { - - // 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)?.arn { - self.queueByArn = arn - } else { - let logicalName = Resource.logicalName(resourceType: "Queue", - resourceName: ref) - self.queue = Resource.queue(name: logicalName, - properties: SQSResourceProperties(queueName: ref)) - } - - } - init(_ queue: Resource) { self.queue = queue } - - enum CodingKeys: String, CodingKey { - case queue = "Queue" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - // 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 - if let queueByArn { - try container.encode(queueByArn, forKey: .queue) - } else if let queue { - var getAttIntrinsicFunction: [String:[String]] = [:] - getAttIntrinsicFunction["Fn::GetAtt"] = [ queue.name, "Arn"] - try container.encode(getAttIntrinsicFunction, forKey: .queue) - } +/// 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 queueByArn: String? = nil + public var queue: Resource? = nil + + init(byRef ref: String) { + + // 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)?.arn { + self.queueByArn = arn + } else { + let logicalName = Resource.logicalName( + resourceType: "Queue", + resourceName: ref) + self.queue = Resource.queue( + name: logicalName, + properties: SQSResourceProperties(queueName: ref)) } + + } + init(_ queue: Resource) { self.queue = queue } + + enum CodingKeys: String, CodingKey { + case queue = "Queue" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // 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 + if let queueByArn { + try container.encode(queueByArn, forKey: .queue) + } else if let queue { + var getAttIntrinsicFunction: [String: [String]] = [:] + getAttIntrinsicFunction["Fn::GetAtt"] = [queue.name, "Arn"] + try container.encode(getAttIntrinsicFunction, forKey: .queue) + } + } } //MARK: SQS queue resource definition /*--------------------------------------------------------------------------------------- SQS Queue Resource - + Documentation https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html -----------------------------------------------------------------------------------------*/ extension Resource { - public static func queue(name: String, - properties: SQSResourceProperties) -> Resource { - - return Resource(type: "AWS::SQS::Queue", - properties: properties, - name: name) - } - - public static func queue(logicalName: String, - physicalName: String ) -> Resource { - - let sqsProperties = SQSResourceProperties(queueName: physicalName) - return queue(name: logicalName, properties: sqsProperties) - } + internal static func queue( + name: String, + properties: SQSResourceProperties + ) -> Resource { + + return Resource( + type: .queue, + properties: properties, + name: name) + } + + public static func queue( + logicalName: String, + physicalName: String + ) -> Resource { + + let sqsProperties = SQSResourceProperties(queueName: physicalName) + return queue(name: logicalName, properties: sqsProperties) + } } public struct SQSResourceProperties: SAMResourceProperties { - public let queueName: String - enum CodingKeys: String, CodingKey { - case queueName = "QueueName" - } + public let queueName: String + enum CodingKeys: String, CodingKey { + case queueName = "QueueName" + } } //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 -----------------------------------------------------------------------------------------*/ extension Resource { - public static func table(name: String, - properties: SimpleTableProperties) -> Resource { - - return Resource(type: "AWS::Serverless::SimpleTable", - properties: properties, - name: name) - } - public static func table(logicalName: String, - physicalName: String, - primaryKeyName: String, - primaryKeyType: String) -> Resource { - let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) - let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) - return table(name: logicalName, properties: properties) - }} + internal static func table( + name: String, + properties: SimpleTableProperties + ) -> Resource { + + return Resource( + type: .table, + properties: properties, + name: name) + } + public static func table( + logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String + ) -> Resource { + let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) + let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) + return table(name: logicalName, properties: properties) + } +} public struct SimpleTableProperties: SAMResourceProperties { - let primaryKey: PrimaryKey - let tableName: String - let provisionedThroughput: ProvisionedThroughput? = nil - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try? container.encode(tableName, forKey: .tableName) - try? container.encode(primaryKey, forKey: .primaryKey) - if let provisionedThroughput = self.provisionedThroughput { - try container.encode(provisionedThroughput, forKey: .provisionedThroughput) - } - } + let primaryKey: PrimaryKey + let tableName: String + let provisionedThroughput: ProvisionedThroughput? = nil + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encode(tableName, forKey: .tableName) + try? container.encode(primaryKey, forKey: .primaryKey) + if let provisionedThroughput = self.provisionedThroughput { + try container.encode(provisionedThroughput, forKey: .provisionedThroughput) + } + } + enum CodingKeys: String, CodingKey { + case primaryKey = "PrimaryKey" + case tableName = "TableName" + case provisionedThroughput = "ProvisionedThroughput" + } + struct PrimaryKey: Codable { + let name: String + let type: String enum CodingKeys: String, CodingKey { - case primaryKey = "PrimaryKey" - case tableName = "TableName" - case provisionedThroughput = "ProvisionedThroughput" - } - struct PrimaryKey: Codable { - let name: String - let type: String - enum CodingKeys: String, CodingKey { - case name = "Name" - case type = "Type" - } + case name = "Name" + case type = "Type" } - struct ProvisionedThroughput: Codable { - let readCapacityUnits: Int - let writeCapacityUnits: Int - enum CodingKeys: String, CodingKey { - case readCapacityUnits = "ReadCapacityUnits" - case writeCapacityUnits = "WriteCapacityUnits" - } + } + struct ProvisionedThroughput: Codable { + let readCapacityUnits: Int + let writeCapacityUnits: Int + enum CodingKeys: String, CodingKey { + case readCapacityUnits = "ReadCapacityUnits" + case writeCapacityUnits = "WriteCapacityUnits" } + } } - //MARK: Utils struct Arn { - public let arn: String - init?(_ arn: String) { - // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - let arnRegex = #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# - if arn.range(of: arnRegex, options: .regularExpression) != nil { - self.arn = arn - } else { - return nil - } - } + public let arn: String + init?(_ arn: String) { + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + let arnRegex = + #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + if arn.range(of: arnRegex, options: .regularExpression) != nil { + self.arn = arn + } else { + return nil + } + } } 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 - } -} \ No newline at end of file + // 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..cd464e9c --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -0,0 +1,385 @@ +// ===----------------------------------------------------------------------===// +// +// 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 Foundation +import Yams + +// 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 { + + // MARK: Generation of the SAM Deployment Descriptor + + private init( + description: String, + resources: [Resource] + ) { + + // create SAM deployment descriptor and register it for serialization + __deploymentDescriptor = SAMDeploymentDescriptor( + description: description, + resources: resources) + + // 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 }) + } + @available( + *, unavailable, + message: "The first statement of DeploymentDescriptor must be its description String" + ) + public static func buildBlock(_ resources: [Resource]...) -> ( + String, [Resource] + ) { + fatalError() + } + public static func buildFinalResult(_ function: (String, [Resource])) + -> DeploymentDescriptor + { + return DeploymentDescriptor(description: function.0, resources: function.1) + } + public static func buildExpression(_ expression: String) -> String { + return expression + } + public static func buildExpression(_ expression: Function) -> [Resource] { + return expression.resources() + } + public static func buildExpression(_ expression: Queue) -> [Resource] { + return [expression.resource()] + } + public static func buildExpression(_ expression: Table) -> [Resource] { + return [expression.resource()] + } + public static func buildExpression(_ expression: Resource) -> [Resource< + ResourceType + >] { + return [expression] + } +} + +// MARK: Function resource + +public struct Function { + let name: String + let architecture: Architectures + let eventSources: [Resource] + let environment: [String: String] + + private init( + name: String, + architecture: Architectures = Architectures.defaultArchitecture(), + eventSources: [Resource] = [], + environment: [String: String] = [:] + ) { + self.name = name + self.architecture = architecture + self.eventSources = eventSources + self.environment = environment + } + public init( + name: String, + architecture: Architectures = Architectures.defaultArchitecture(), + @FunctionBuilder _ builder: () -> (EventSources, [String: String]) + ) { + + let (eventSources, environmentVariables) = builder() + let samEventSource: [Resource] = eventSources.samEventSources() + self.init( + name: name, + architecture: architecture, + eventSources: samEventSource, + environment: environmentVariables) + } + + internal func resources() -> [Resource] { + + let functionResource = [ + Resource.serverlessFunction( + name: self.name, + architecture: self.architecture, + codeUri: packagePath(), + eventSources: self.eventSources, + environment: SAMEnvironmentVariable(self.environment)) + ] + let additionalQueueResources = collectQueueResources() + + return functionResource + additionalQueueResources + } + + // compute the path for the lambda archive + private func packagePath() -> 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/\(self.name)/\(self.name).zip" + if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { + if CommandLine.arguments.count >= optIdx + 1 { + let archiveArg = CommandLine.arguments[optIdx + 1] + lambdaPackage = "\(archiveArg)/\(self.name)/\(self.name).zip" + } + } + + // check the ZIP file exists + if !FileManager.default.fileExists(atPath: lambdaPackage) { + // TODO: add support for fatalError() in unit tests + fatalError("Error: package does not exist at path: \(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] { + + return self.eventSources + // first filter on event sources of type SQS where the `queue` property is defined (not nil) + .filter { lambdaEventSource in + lambdaEventSource.type == .sqs + && (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil + } + // next extract the queue resource part of the sqsEventSource + .compactMap { + sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue + } + } + + // MARK: Function DSL code + @resultBuilder + public enum FunctionBuilder { + public static func buildBlock(_ events: EventSources, _ variables: EnvironmentVariables) -> ( + EventSources, [String: String] + ) { + return (events, variables.environmentVariables) + } + public static func buildBlock(_ variables: EnvironmentVariables, _ events: EventSources) -> ( + EventSources, [String: String] + ) { + return (events, variables.environmentVariables) + } + @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") + public static func buildBlock(_ events: EventSources, _ components: EnvironmentVariables...) + -> (EventSources, [String: String]) + { + fatalError() + } + } + +} + +// MARK: Event Source +public struct EventSources { + private let eventSources: [Resource] + public init(@EventSourceBuilder _ builder: () -> [Resource]) { + self.eventSources = builder() + } + internal func samEventSources() -> [Resource] { + return self.eventSources + } + // MARK: EventSources DSL code + @resultBuilder + public enum EventSourceBuilder { + public static func buildBlock(_ source: Resource...) -> [Resource< + EventSourceType + >] { + return source.compactMap { $0 } + } + public static func buildExpression(_ expression: HttpApi) -> Resource { + return expression.resource() + } + public static func buildExpression(_ expression: Sqs) -> Resource { + return expression.resource() + } + } +} + +// MARK: HttpApi event source +public struct HttpApi { + private let method: HttpVerb? + private let path: String? + public init( + method: HttpVerb? = nil, + path: String? = nil + ) { + self.method = method + self.path = path + } + internal func resource() -> Resource { + return Resource.httpApi(method: self.method, path: self.path) + } +} + +// MARK: SQS Event Source +public struct Sqs { + private let name: String + private var queueRef: String? = nil + private var queue: Queue? = nil + public init(name: String = "SQSEvent") { + self.name = name + } + public init(name: String = "SQSEvent", _ queue: String) { + self.name = name + self.queueRef = queue + } + public init(name: String = "SQSEvent", _ queue: Queue) { + self.name = name + self.queue = queue + } + 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 { + if self.queue != nil { + return Resource.sqs(name: self.name, queue: self.queue!.resource()) + } else if self.queueRef != nil { + return Resource.sqs(name: self.name, queue: self.queueRef!) + } else { + fatalError("Either queue or queueRef muts have a value") + } + } +} + +// 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 +public struct Queue { + let logicalName: String + let physicalName: String + public init(logicalName: String, physicalName: String) { + self.logicalName = logicalName + self.physicalName = physicalName + } + internal func resource() -> Resource { + return Resource.queue( + logicalName: self.logicalName, + physicalName: self.physicalName) + } +} + +// MARK: Table top level resource +public struct Table { + let logicalName: String + let physicalName: String + let primaryKeyName: String + let primaryKeyType: String + public init( + logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String + ) { + + self.logicalName = logicalName + self.physicalName = physicalName + self.primaryKeyName = primaryKeyName + self.primaryKeyType = primaryKeyType + } + internal func resource() -> Resource { + return Resource.table( + logicalName: self.logicalName, + physicalName: self.physicalName, + primaryKeyName: self.primaryKeyName, + primaryKeyType: self.primaryKeyType) + } +} + +// MARK: Serialization code + +extension SAMDeploymentDescriptor { + + fileprivate func toJSON(pretty: Bool = true) -> String { + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = .prettyPrinted + } + let jsonData = try! encoder.encode(self) + return String(data: jsonData, encoding: .utf8)! + } + + fileprivate func toYAML() -> String { + let yaml = try! YAMLEncoder().encode(self) + return yaml + } +} + +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 { + guard let fd = fdopen(fileDesc, "w") else { return } + switch format { + case .json: fputs(deploymentDescriptor.toJSON(), fd) + case .yaml: fputs(deploymentDescriptor.toYAML(), fd) + } + + fclose(fd) + } +} From 160d4a4be0d8b6d9e310c587598f842ec96b0028 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 07:30:34 +0100 Subject: [PATCH 42/79] add deployment descriptor unit tests --- Plugins/AWSLambdaPackager/Plugin.swift | 4 +- .../DeploymentDescriptorBuilder.swift | 4 +- .../DeploymentDescriptorTests.swift | 66 +++++++++---------- .../MockedDeploymentDescriptor.swift | 26 ++++---- 4 files changed, 48 insertions(+), 52 deletions(-) diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index dae18f62..4afd67a8 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -112,8 +112,8 @@ struct AWSLambdaPackager: CommandPlugin { let buildCommand = "swift build -c \(buildConfiguration.rawValue) --product \(product.name) --static-swift-stdlib" try Utils.execute( executable: dockerToolPath, - arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], - logLevel: verboseLogging ? .debug : .output + // arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], + arguments: ["run", "--rm", "-v", "\(packageDirectory.string)/../..:/workspace", "-w", "/workspace/Examples/SAM", baseImage, "bash", "-cl", buildCommand], logLevel: verboseLogging ? .debug : .output ) let productPath = buildOutputPath.appending(product.name) guard FileManager.default.fileExists(atPath: productPath.string) else { diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index cd464e9c..39a283b2 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -347,7 +347,7 @@ public struct Table { extension SAMDeploymentDescriptor { - fileprivate func toJSON(pretty: Bool = true) -> String { + internal func toJSON(pretty: Bool = true) -> String { let encoder = JSONEncoder() if pretty { encoder.outputFormatting = .prettyPrinted @@ -356,7 +356,7 @@ extension SAMDeploymentDescriptor { return String(data: jsonData, encoding: .utf8)! } - fileprivate func toYAML() -> String { + internal func toYAML() -> String { let yaml = try! YAMLEncoder().encode(self) return yaml } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 37ba5b43..83be441c 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -17,10 +17,13 @@ import XCTest final class DeploymentDescriptorTest: XCTestCase { - private func generateAndTestDeploymentDecsriptor(deployment: MockDeploymentDescriptor, expected: String) -> Bool { + private func generateAndTestDeploymentDescriptor(deployment: MockDeploymentDescriptor, expected: String) -> Bool { // when let samJSON = deployment.deploymentDescriptor.toJSON(pretty: false) - + print(samJSON) + print("====") + print(expected) + // then return samJSON.contains(expected) } @@ -33,7 +36,7 @@ final class DeploymentDescriptorTest: XCTestCase { """ let testDeployment = MockDeploymentDescriptor(withFunction: false) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -41,10 +44,10 @@ final class DeploymentDescriptorTest: XCTestCase { // given let expected = """ -function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} +function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -52,11 +55,11 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.x64.rawValue)"]}}} +function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.x64.rawValue)"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, architecture: .x64) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -74,7 +77,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty primaryKeyName: "pk", primaryKeyType: "String")] ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -91,7 +94,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty properties: SQSResourceProperties(queueName: "queue-name"))] ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -99,13 +102,13 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .httpApi() ] ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -113,13 +116,13 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .httpApi(method: .GET, path: "/test") ]) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } // @@ -127,33 +130,26 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"### ERROR package does not exist: .build\\/plugins\\/AWSLambdaPackager\\/outputs\\/AWSLambdaPackager\\/TestLambda\\/TestLambda.zip ###","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } func testSQSEventSourceWithoutArn() { // given - var expected = """ -"QueueQueueLambdaTest":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"queue-lambda-test"}} -""" - let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .sqs(queue: "queue-lambda-test") ] ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, - expected: expected)) - - expected = """ + let expected = """ "Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]}}}} """ - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -169,13 +165,13 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty let testDeployment = MockDeploymentDescriptor(withFunction: true, - environmentVariable: EnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", - "TEST2_VAR": "TEST2_VALUE"]) ) + environmentVariable: SAMEnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", + "TEST2_VAR": "TEST2_VALUE"]) ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expectedinOrder) || - self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expectedOutOfOrder) ) } @@ -187,11 +183,11 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty "Environment":{"Variables":{"TEST1_VAR":{"Ref":"TEST1_VALUE"}}} """ - var envVar = EnvironmentVariable() + var envVar = SAMEnvironmentVariable() envVar.append("TEST1_VAR", ["Ref" : "TEST1_VALUE"]) let testDeployment = MockDeploymentDescriptor(withFunction: true, environmentVariable: envVar ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -202,11 +198,11 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty "Environment":{"Variables":{"TEST1_VAR":{"Fn::GetAtt":["TEST1_VALUE","Arn"]}}} """ - var envVar = EnvironmentVariable() + var envVar = SAMEnvironmentVariable() envVar.append("TEST1_VAR", ["Fn::GetAtt" : ["TEST1_VALUE", "Arn"]]) let testDeployment = MockDeploymentDescriptor(withFunction: true, environmentVariable: envVar ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -217,12 +213,12 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty "Environment":{"Variables":{"TEST1_VAR":{"Ref":"LogicalName"}}} """ - let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") - var envVar = EnvironmentVariable() + let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") + var envVar = SAMEnvironmentVariable() envVar.append("TEST1_VAR", resource) let testDeployment = MockDeploymentDescriptor(withFunction: true, environmentVariable: envVar ) - XCTAssertTrue(self.generateAndTestDeploymentDecsriptor(deployment: testDeployment, + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index 47a3e923..bb0f0040 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -17,32 +17,32 @@ import AWSLambdaDeploymentDescriptor struct MockDeploymentDescriptor { - let deploymentDescriptor : DeploymentDefinition + let deploymentDescriptor : SAMDeploymentDescriptor init(withFunction: Bool = true, architecture: Architectures = Architectures.defaultArchitecture(), - eventSource: [EventSource]? = nil, - environmentVariable: EnvironmentVariable? = nil, - additionalResources: [Resource]? = nil) + eventSource: [Resource]? = nil, + environmentVariable: SAMEnvironmentVariable? = nil, + additionalResources: [Resource]? = nil) { if withFunction { - self.deploymentDescriptor = DeploymentDefinition( + self.deploymentDescriptor = SAMDeploymentDescriptor( description: "A SAM template to deploy a Swift Lambda function", - functions: [ - .function( + resources: [ + .serverlessFunction( name: "TestLambda", architecture: architecture, + codeUri: "/path/lambda.zip", eventSources: eventSource ?? [], - environment: environmentVariable ?? EnvironmentVariable.none + environment: environmentVariable ?? SAMEnvironmentVariable.none ) - ], - resources: additionalResources ?? [] + ] + (additionalResources ?? []) + ) } else { - self.deploymentDescriptor = DeploymentDefinition( + self.deploymentDescriptor = SAMDeploymentDescriptor( description: "A SAM template to deploy a Swift Lambda function", - functions: [], - resources: additionalResources ?? [] + resources: (additionalResources ?? []) ) } } From 4fa3873a06f09dfea8e2caadac273d380a23c473 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 07:33:02 +0100 Subject: [PATCH 43/79] adjust copyright to 2023 --- Plugins/AWSLambdaDeployer/Plugin.swift | 2 +- .../AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift | 2 +- .../DeploymentDescriptorBuilder.swift | 2 +- .../DeploymentDescriptorTests.swift | 2 +- .../MockedDeploymentDescriptor.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 804d9702..58444b13 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftAWSLambdaRuntime open source project // -// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index d2840fe1..390863ee 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftAWSLambdaRuntime open source project // -// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index 39a283b2..a0ee7d74 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftAWSLambdaRuntime open source project // -// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 83be441c..7d81b6b2 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftAWSLambdaRuntime open source project // -// Copyright (c) 2017-2018 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Copyright (c) 2017-2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index bb0f0040..1979515f 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftAWSLambdaRuntime open source project // -// Copyright (c) 2017-2018 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Copyright (c) 2017-2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information From 4cc294ae78673ac79f2298ce947c85a3c70caad3 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 12:20:47 +0100 Subject: [PATCH 44/79] add unit test for deployment descriptor builder --- Examples/SAM/Makefile | 5 +- Plugins/AWSLambdaDeployer/Plugin.swift | 36 +- Plugins/AWSLambdaPackager/Plugin.swift | 3 +- .../DeploymentDescriptor.swift | 850 +++++++++--------- .../DeploymentDescriptorBuilder.swift | 648 ++++++------- .../DeploymentDescriptorBuilderTests.swift | 155 ++++ .../DeploymentDescriptorTests.swift | 117 ++- .../MockedDeploymentDescriptor.swift | 54 +- 8 files changed, 1044 insertions(+), 824 deletions(-) create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile index 616df9b3..da26da9f 100644 --- a/Examples/SAM/Makefile +++ b/Examples/SAM/Makefile @@ -4,9 +4,12 @@ build: update: swift package update -test: +testlambda: swift test +test: + (cd ../.. && swift test --filter AWSLambdaDeploymentDescriptor) + release: swift build -c release diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 58444b13..337de09f 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -60,8 +60,8 @@ struct AWSLambdaPackager: CommandPlugin { stackName : configuration.stackName, verboseLogging: configuration.verboseLogging) } - - // list endpoints + + // list endpoints if !configuration.noList { let output = try self.listEndpoints(samExecutablePath: samExecutablePath, samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, @@ -223,11 +223,11 @@ struct AWSLambdaPackager: CommandPlugin { 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.") + 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) + throw DeployerPluginError.error(error) } } } catch { @@ -237,9 +237,9 @@ struct AWSLambdaPackager: CommandPlugin { } private func listEndpoints(samExecutablePath: Path, - samDeploymentDescriptorFilePath: String, - stackName: String, - verboseLogging: Bool) throws -> String { + samDeploymentDescriptorFilePath: String, + stackName: String, + verboseLogging: Bool) throws -> String { //TODO: check if there is a samconfig.toml file. // when there is no file, generate one with default data or data collected from params @@ -251,18 +251,18 @@ struct AWSLambdaPackager: CommandPlugin { do { return try Utils.execute( - executable: samExecutablePath, - arguments: ["list", "endpoints", - "-t", samDeploymentDescriptorFilePath, - "--stack-name", stackName, - "--output", "json"], - logLevel: verboseLogging ? .debug : .silent) + 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) } } - + private func displayHelpMessage() { print(""" OVERVIEW: A swift plugin to deploy your Lambda function on your AWS account. @@ -326,8 +326,8 @@ private struct Configuration: CustomStringConvertible { // define deployment option self.noDeploy = nodeployArgument - - // define control on list endpoints after a deployment + + // define control on list endpoints after a deployment self.noList = noListArgument // define logging verbosity @@ -362,7 +362,7 @@ private struct Configuration: CustomStringConvertible { if let stackName = stackNameArgument.first { self.stackName = stackName } else { - self.stackName = context.package.displayName + self.stackName = context.package.displayName } if self.verboseLogging { diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index 4afd67a8..e23711d0 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -112,8 +112,7 @@ struct AWSLambdaPackager: CommandPlugin { let buildCommand = "swift build -c \(buildConfiguration.rawValue) --product \(product.name) --static-swift-stdlib" try Utils.execute( executable: dockerToolPath, - // arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], - arguments: ["run", "--rm", "-v", "\(packageDirectory.string)/../..:/workspace", "-w", "/workspace/Examples/SAM", baseImage, "bash", "-cl", buildCommand], logLevel: verboseLogging ? .debug : .output + arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], ) let productPath = buildOutputPath.appending(product.name) guard FileManager.default.fileExists(atPath: productPath.string) else { diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 390863ee..1bd343e8 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -21,28 +21,28 @@ import Foundation // 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 = "A SAM template to deploy a Swift Lambda function", - resources: [Resource] = [] - ) { - self.description = description - for res in resources { - self.resources[res.name] = res - } - } - - enum CodingKeys: String, CodingKey { - case templateVersion = "AWSTemplateFormatVersion" - case transform = "Transform" - case description = "Description" - case resources = "Resources" - } + + 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 = "A SAM template to deploy a Swift Lambda function", + resources: [Resource] = [] + ) { + self.description = description + for res in resources { + self.resources[res.name] = res + } + } + + enum CodingKeys: String, CodingKey { + case templateVersion = "AWSTemplateFormatVersion" + case transform = "Transform" + case description = "Description" + case resources = "Resources" + } } public protocol SAMResource: Encodable {} @@ -50,142 +50,142 @@ public protocol SAMResourceType: Encodable, Equatable {} public protocol SAMResourceProperties: Encodable {} public enum ResourceType: String, SAMResourceType { - case serverlessFunction = "AWS::Serverless::Function" - case queue = "AWS::SQS::Queue" - case table = "AWS::Serverless::SimpleTable" + case serverlessFunction = "AWS::Serverless::Function" + case queue = "AWS::SQS::Queue" + case table = "AWS::Serverless::SimpleTable" } public enum EventSourceType: String, SAMResourceType { - case httpApi = "HttpApi" - case sqs = "SQS" + case httpApi = "HttpApi" + case sqs = "SQS" } // generic type to represent either a top-level resource or an event source public struct Resource: SAMResource, Equatable { - - 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: String, CodingKey { - case type = "Type" - case properties = "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) - } - } + + 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: String, CodingKey { + case type = "Type" + case properties = "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 -----------------------------------------------------------------------------------------*/ extension Resource { - public static func serverlessFunction( - name: String, - architecture: Architectures, - codeUri: String?, - eventSources: [Resource] = [], - environment: SAMEnvironmentVariable = .none - ) -> Resource { - - let properties = ServerlessFunctionProperties( - codeUri: codeUri, - architecture: architecture, - eventSources: eventSources, - environment: environment) - return Resource( - type: .serverlessFunction, - properties: properties, - name: name) - } + public static func serverlessFunction( + name: String, + architecture: Architectures, + codeUri: String?, + eventSources: [Resource] = [], + environment: SAMEnvironmentVariable = .none + ) -> Resource { + + let properties = ServerlessFunctionProperties( + codeUri: codeUri, + architecture: architecture, + eventSources: eventSources, + environment: environment) + return Resource( + type: .serverlessFunction, + properties: properties, + name: name) + } } 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: ", ") - } + 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: ", ") + } } public struct ServerlessFunctionProperties: SAMResourceProperties { - let architectures: [Architectures] - let handler: String - let runtime: String - let codeUri: String? - let autoPublishAlias: String - var eventSources: [String: Resource] - var environment: SAMEnvironmentVariable - - public init( - codeUri: String?, - architecture: Architectures, - eventSources: [Resource] = [], - environment: SAMEnvironmentVariable = .none - ) { - - self.architectures = [architecture] - self.handler = "Provided" - self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 - self.autoPublishAlias = "Live" - self.codeUri = codeUri - self.eventSources = [:] - self.environment = environment - - for es in eventSources { - self.eventSources[es.name] = es + let architectures: [Architectures] + let handler: String + let runtime: String + let codeUri: String? + let autoPublishAlias: String + var eventSources: [String: Resource] + var environment: SAMEnvironmentVariable + + public init( + codeUri: String?, + architecture: Architectures, + eventSources: [Resource] = [], + environment: SAMEnvironmentVariable = .none + ) { + + self.architectures = [architecture] + self.handler = "Provided" + self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 + self.autoPublishAlias = "Live" + self.codeUri = codeUri + self.eventSources = [:] + self.environment = environment + + for es in eventSources { + self.eventSources[es.name] = es + } + } + + // custom encoding to not provide Environment variables when there is none + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.architectures, forKey: .architectures) + try container.encode(self.handler, forKey: .handler) + try container.encode(self.runtime, forKey: .runtime) + if let codeUri = self.codeUri { + try container.encode(codeUri, forKey: .codeUri) + } + try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) + try container.encode(self.eventSources, forKey: .eventSources) + if !environment.isEmpty() { + try container.encode(self.environment, forKey: .environment) + } + } + + enum CodingKeys: String, CodingKey { + case architectures = "Architectures" + case handler = "Handler" + case runtime = "Runtime" + case codeUri = "CodeUri" + case autoPublishAlias = "AutoPublishAlias" + case eventSources = "Events" + case environment = "Environment" } - } - - // custom encoding to not provide Environment variables when there is none - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.architectures, forKey: .architectures) - try container.encode(self.handler, forKey: .handler) - try container.encode(self.runtime, forKey: .runtime) - if let codeUri = self.codeUri { - try container.encode(codeUri, forKey: .codeUri) - } - try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) - try container.encode(self.eventSources, forKey: .eventSources) - if !environment.isEmpty() { - try container.encode(self.environment, forKey: .environment) - } - } - - enum CodingKeys: String, CodingKey { - case architectures = "Architectures" - case handler = "Handler" - case runtime = "Runtime" - case codeUri = "CodeUri" - case autoPublishAlias = "AutoPublishAlias" - case eventSources = "Events" - case environment = "Environment" - } } /* @@ -194,361 +194,361 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { 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 } + + 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: String, CodingKey { + case variables = "Variables" + } + + public func encode(to encoder: Encoder) throws { + 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]]) + } + + private 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) } } - - 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: String, CodingKey { - case variables = "Variables" - } - - public func encode(to encoder: Encoder) throws { - 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]]) - } - - private 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 -----------------------------------------------------------------------------------------*/ extension Resource { - public static func httpApi( - name: String = "HttpApiEvent", - method: HttpVerb? = nil, - path: String? = nil - ) -> Resource { - - var properties: SAMResourceProperties? = nil - if method != nil || path != nil { - properties = HttpApiProperties(method: method, path: path) + public static func httpApi( + name: String = "HttpApiEvent", + method: HttpVerb? = nil, + path: String? = nil + ) -> Resource { + + var properties: SAMResourceProperties? = nil + if method != nil || path != nil { + properties = HttpApiProperties(method: method, path: path) + } + + return Resource( + type: .httpApi, + properties: properties, + name: name) } - - return Resource( - type: .httpApi, - properties: properties, - name: name) - } } struct HttpApiProperties: SAMResourceProperties, Equatable { - init(method: HttpVerb? = nil, path: String? = nil) { - self.method = method - self.path = path - } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: HttpApiKeys.self) - if let method = self.method { - try container.encode(method, forKey: .method) - } - if let path = self.path { - try container.encode(path, forKey: .path) - } - } - let method: HttpVerb? - let path: String? - - enum HttpApiKeys: String, CodingKey { - case method = "Method" - case path = "Path" - } + init(method: HttpVerb? = nil, path: String? = nil) { + self.method = method + self.path = path + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: HttpApiKeys.self) + if let method = self.method { + try container.encode(method, forKey: .method) + } + if let path = self.path { + try container.encode(path, forKey: .path) + } + } + let method: HttpVerb? + let path: String? + + enum HttpApiKeys: String, CodingKey { + case method = "Method" + case path = "Path" + } } public enum HttpVerb: String, Encodable { - case GET - case POST + case GET + case POST } //MARK: SQS event definition /*--------------------------------------------------------------------------------------- SQS Event - + https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html -----------------------------------------------------------------------------------------*/ extension Resource { - internal static func sqs( - name: String = "SQSEvent", - properties: SQSEventProperties - ) -> Resource { - - return Resource( - type: .sqs, - properties: properties, - name: name) - } - public static func sqs( - name: String = "SQSEvent", - queue queueRef: String - ) -> Resource { - - let properties = SQSEventProperties(byRef: queueRef) - return Resource.sqs( - name: name, - properties: properties) - } - - public static func sqs( - name: String = "SQSEvent", - queue: Resource - ) -> Resource { - - let properties = SQSEventProperties(queue) - return Resource.sqs( - name: name, - properties: properties) - } + internal static func sqs( + name: String = "SQSEvent", + properties: SQSEventProperties + ) -> Resource { + + return Resource( + type: .sqs, + properties: properties, + name: name) + } + public static func sqs( + name: String = "SQSEvent", + queue queueRef: String + ) -> Resource { + + let properties = SQSEventProperties(byRef: queueRef) + return Resource.sqs( + name: name, + properties: properties) + } + + public static func sqs( + name: String = "SQSEvent", + queue: Resource + ) -> Resource { + + let properties = SQSEventProperties(queue) + return Resource.sqs( + name: name, + properties: properties) + } } /// 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 queueByArn: String? = nil - public var queue: Resource? = nil - - init(byRef ref: String) { - - // 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)?.arn { - self.queueByArn = arn - } else { - let logicalName = Resource.logicalName( - resourceType: "Queue", - resourceName: ref) - self.queue = Resource.queue( - name: logicalName, - properties: SQSResourceProperties(queueName: ref)) + + public var queueByArn: String? = nil + public var queue: Resource? = nil + + init(byRef ref: String) { + + // 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)?.arn { + self.queueByArn = arn + } else { + let logicalName = Resource.logicalName( + resourceType: "Queue", + resourceName: ref) + self.queue = Resource.queue( + name: logicalName, + properties: SQSResourceProperties(queueName: ref)) + } + + } + init(_ queue: Resource) { self.queue = queue } + + enum CodingKeys: String, CodingKey { + case queue = "Queue" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // 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 + if let queueByArn { + try container.encode(queueByArn, forKey: .queue) + } else if let queue { + var getAttIntrinsicFunction: [String: [String]] = [:] + getAttIntrinsicFunction["Fn::GetAtt"] = [queue.name, "Arn"] + try container.encode(getAttIntrinsicFunction, forKey: .queue) + } } - - } - init(_ queue: Resource) { self.queue = queue } - - enum CodingKeys: String, CodingKey { - case queue = "Queue" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - // 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 - if let queueByArn { - try container.encode(queueByArn, forKey: .queue) - } else if let queue { - var getAttIntrinsicFunction: [String: [String]] = [:] - getAttIntrinsicFunction["Fn::GetAtt"] = [queue.name, "Arn"] - try container.encode(getAttIntrinsicFunction, forKey: .queue) - } - } } //MARK: SQS queue resource definition /*--------------------------------------------------------------------------------------- SQS Queue Resource - + Documentation https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html -----------------------------------------------------------------------------------------*/ extension Resource { - internal static func queue( - name: String, - properties: SQSResourceProperties - ) -> Resource { - - return Resource( - type: .queue, - properties: properties, - name: name) - } - - public static func queue( - logicalName: String, - physicalName: String - ) -> Resource { - - let sqsProperties = SQSResourceProperties(queueName: physicalName) - return queue(name: logicalName, properties: sqsProperties) - } + internal static func queue( + name: String, + properties: SQSResourceProperties + ) -> Resource { + + return Resource( + type: .queue, + properties: properties, + name: name) + } + + public static func queue( + logicalName: String, + physicalName: String + ) -> Resource { + + let sqsProperties = SQSResourceProperties(queueName: physicalName) + return queue(name: logicalName, properties: sqsProperties) + } } public struct SQSResourceProperties: SAMResourceProperties { - public let queueName: String - enum CodingKeys: String, CodingKey { - case queueName = "QueueName" - } + public let queueName: String + enum CodingKeys: String, CodingKey { + case queueName = "QueueName" + } } //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 -----------------------------------------------------------------------------------------*/ extension Resource { - internal static func table( - name: String, - properties: SimpleTableProperties - ) -> Resource { - - return Resource( - type: .table, - properties: properties, - name: name) - } - public static func table( - logicalName: String, - physicalName: String, - primaryKeyName: String, - primaryKeyType: String - ) -> Resource { - let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) - let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) - return table(name: logicalName, properties: properties) - } + internal static func table( + name: String, + properties: SimpleTableProperties + ) -> Resource { + + return Resource( + type: .table, + properties: properties, + name: name) + } + public static func table( + logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String + ) -> Resource { + let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) + let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) + return table(name: logicalName, properties: properties) + } } public struct SimpleTableProperties: SAMResourceProperties { - let primaryKey: PrimaryKey - let tableName: String - let provisionedThroughput: ProvisionedThroughput? = nil - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try? container.encode(tableName, forKey: .tableName) - try? container.encode(primaryKey, forKey: .primaryKey) - if let provisionedThroughput = self.provisionedThroughput { - try container.encode(provisionedThroughput, forKey: .provisionedThroughput) - } - } - enum CodingKeys: String, CodingKey { - case primaryKey = "PrimaryKey" - case tableName = "TableName" - case provisionedThroughput = "ProvisionedThroughput" - } - struct PrimaryKey: Codable { - let name: String - let type: String - enum CodingKeys: String, CodingKey { - case name = "Name" - case type = "Type" + let primaryKey: PrimaryKey + let tableName: String + let provisionedThroughput: ProvisionedThroughput? = nil + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encode(tableName, forKey: .tableName) + try? container.encode(primaryKey, forKey: .primaryKey) + if let provisionedThroughput = self.provisionedThroughput { + try container.encode(provisionedThroughput, forKey: .provisionedThroughput) + } } - } - struct ProvisionedThroughput: Codable { - let readCapacityUnits: Int - let writeCapacityUnits: Int enum CodingKeys: String, CodingKey { - case readCapacityUnits = "ReadCapacityUnits" - case writeCapacityUnits = "WriteCapacityUnits" + case primaryKey = "PrimaryKey" + case tableName = "TableName" + case provisionedThroughput = "ProvisionedThroughput" + } + struct PrimaryKey: Codable { + let name: String + let type: String + enum CodingKeys: String, CodingKey { + case name = "Name" + case type = "Type" + } + } + struct ProvisionedThroughput: Codable { + let readCapacityUnits: Int + let writeCapacityUnits: Int + enum CodingKeys: String, CodingKey { + case readCapacityUnits = "ReadCapacityUnits" + case writeCapacityUnits = "WriteCapacityUnits" + } } - } } //MARK: Utils struct Arn { - public let arn: String - init?(_ arn: String) { - // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - let arnRegex = - #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# - if arn.range(of: arnRegex, options: .regularExpression) != nil { - self.arn = arn - } else { - return nil - } - } + public let arn: String + init?(_ arn: String) { + // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn + let arnRegex = + #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# + if arn.range(of: arnRegex, options: .regularExpression) != nil { + self.arn = arn + } else { + return nil + } + } } 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 - } + // 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 index a0ee7d74..31f81508 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -22,364 +22,380 @@ private var __deploymentDescriptor: SAMDeploymentDescriptor? // a top level DeploymentDescriptor DSL @resultBuilder public struct DeploymentDescriptor { - - // MARK: Generation of the SAM Deployment Descriptor - - private init( - description: String, - resources: [Resource] - ) { - - // create SAM deployment descriptor and register it for serialization - __deploymentDescriptor = SAMDeploymentDescriptor( - description: description, - resources: resources) - - // at exit of this process, - // we flush a YAML representation of the deployment descriptor to stdout - atexit { - try! DeploymentDescriptorSerializer.serialize(__deploymentDescriptor!, format: .yaml) + + // capture the deployment decsriptor for unit tests + let samDeploymentDescriptor : SAMDeploymentDescriptor + + // MARK: Generation of the SAM Deployment Descriptor + + private init( + description: String, + resources: [Resource] + ) { + + // create SAM deployment descriptor + 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 }) + } + @available(*, unavailable, + message: "The first statement of DeploymentDescriptor must be its description String" + ) + public static func buildBlock(_ resources: [Resource]...) -> (String, [Resource]) { + fatalError() + } + public static func buildFinalResult(_ function: (String, [Resource])) -> DeploymentDescriptor { + return DeploymentDescriptor(description: function.0, resources: function.1) + } + public static func buildExpression(_ expression: String) -> String { + return expression + } + public static func buildExpression(_ expression: Function) -> [Resource] { + return expression.resources() + } + public static func buildExpression(_ expression: Queue) -> [Resource] { + return [expression.resource()] + } + public static func buildExpression(_ expression: Table) -> [Resource] { + return [expression.resource()] + } + public static func buildExpression(_ expression: Resource) -> [Resource] { + return [expression] } - } - - // 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 }) - } - @available( - *, unavailable, - message: "The first statement of DeploymentDescriptor must be its description String" - ) - public static func buildBlock(_ resources: [Resource]...) -> ( - String, [Resource] - ) { - fatalError() - } - public static func buildFinalResult(_ function: (String, [Resource])) - -> DeploymentDescriptor - { - return DeploymentDescriptor(description: function.0, resources: function.1) - } - public static func buildExpression(_ expression: String) -> String { - return expression - } - public static func buildExpression(_ expression: Function) -> [Resource] { - return expression.resources() - } - public static func buildExpression(_ expression: Queue) -> [Resource] { - return [expression.resource()] - } - public static func buildExpression(_ expression: Table) -> [Resource] { - return [expression.resource()] - } - public static func buildExpression(_ expression: Resource) -> [Resource< - ResourceType - >] { - return [expression] - } } // MARK: Function resource - public struct Function { - let name: String - let architecture: Architectures - let eventSources: [Resource] - let environment: [String: String] - - private init( - name: String, - architecture: Architectures = Architectures.defaultArchitecture(), - eventSources: [Resource] = [], - environment: [String: String] = [:] - ) { - self.name = name - self.architecture = architecture - self.eventSources = eventSources - self.environment = environment - } - public init( - name: String, - architecture: Architectures = Architectures.defaultArchitecture(), - @FunctionBuilder _ builder: () -> (EventSources, [String: String]) - ) { - - let (eventSources, environmentVariables) = builder() - let samEventSource: [Resource] = eventSources.samEventSources() - self.init( - name: name, - architecture: architecture, - eventSources: samEventSource, - environment: environmentVariables) - } - - internal func resources() -> [Resource] { - - let functionResource = [ - Resource.serverlessFunction( - name: self.name, - architecture: self.architecture, - codeUri: packagePath(), - eventSources: self.eventSources, - environment: SAMEnvironmentVariable(self.environment)) - ] - let additionalQueueResources = collectQueueResources() - - return functionResource + additionalQueueResources - } - - // compute the path for the lambda archive - private func packagePath() -> 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/\(self.name)/\(self.name).zip" - if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { - if CommandLine.arguments.count >= optIdx + 1 { - let archiveArg = CommandLine.arguments[optIdx + 1] - lambdaPackage = "\(archiveArg)/\(self.name)/\(self.name).zip" - } - } - - // check the ZIP file exists - if !FileManager.default.fileExists(atPath: lambdaPackage) { - // TODO: add support for fatalError() in unit tests - fatalError("Error: package does not exist at path: \(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] { - - return self.eventSources - // first filter on event sources of type SQS where the `queue` property is defined (not nil) - .filter { lambdaEventSource in - lambdaEventSource.type == .sqs - && (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil - } - // next extract the queue resource part of the sqsEventSource - .compactMap { - sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue - } - } - - // MARK: Function DSL code - @resultBuilder - public enum FunctionBuilder { - public static func buildBlock(_ events: EventSources, _ variables: EnvironmentVariables) -> ( - EventSources, [String: String] + let name: String + let architecture: Architectures + let eventSources: [Resource] + let environment: [String: String] + + private init( + name: String, + architecture: Architectures = Architectures.defaultArchitecture(), + eventSources: [Resource] = [], + environment: [String: String] = [:] ) { - return (events, variables.environmentVariables) + self.name = name + self.architecture = architecture + self.eventSources = eventSources + self.environment = environment } - public static func buildBlock(_ variables: EnvironmentVariables, _ events: EventSources) -> ( - EventSources, [String: String] + public init( + name: String, + architecture: Architectures = Architectures.defaultArchitecture(), + @FunctionBuilder _ builder: () -> (EventSources, [String: String]) ) { - return (events, variables.environmentVariables) + + let (eventSources, environmentVariables) = builder() + let samEventSource: [Resource] = eventSources.samEventSources() + self.init( + name: name, + architecture: architecture, + eventSources: samEventSource, + environment: environmentVariables) } - @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") - public static func buildBlock(_ events: EventSources, _ components: EnvironmentVariables...) - -> (EventSources, [String: String]) - { - fatalError() + + // this method fails when the package does not exist at path + internal func resources() -> [Resource] { + + let functionResource = [ + Resource.serverlessFunction( + name: self.name, + architecture: self.architecture, + codeUri: packagePath(), + eventSources: self.eventSources, + environment: SAMEnvironmentVariable(self.environment)) + ] + let additionalQueueResources = collectQueueResources() + + return functionResource + additionalQueueResources } - } - + + // compute the path for the lambda archive + private func packagePath() -> 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/\(self.name)/\(self.name).zip" + if let optIdx = CommandLine.arguments.firstIndex(of: "--archive-path") { + if CommandLine.arguments.count >= optIdx + 1 { + let archiveArg = CommandLine.arguments[optIdx + 1] + lambdaPackage = "\(archiveArg)/\(self.name)/\(self.name).zip" + } + } + + // check the ZIP file exists + if !FileManager.default.fileExists(atPath: lambdaPackage) { + let msg = "Error: package does not exist at path: \(lambdaPackage)" + + // when running in a unit test, return a marker value + // otherwise, fail abruptly + if !Thread.current.isRunningXCTest { + fatalError(msg) + } else { + lambdaPackage = "ERROR" + } + } + + 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] { + return self.eventSources + // first filter on event sources of type SQS where the `queue` property is defined (not nil) + .filter { lambdaEventSource in + lambdaEventSource.type == .sqs + && (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil + } + // next extract the queue resource part of the sqsEventSource + .compactMap { + sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue + } + } + + // MARK: Function DSL code + @resultBuilder + public enum FunctionBuilder { + public static func buildBlock(_ events: EventSources, + _ variables: EnvironmentVariables) -> (EventSources, [String: String]) { + return (events, variables.environmentVariables) + } + public static func buildBlock(_ variables: EnvironmentVariables, + _ events: EventSources) -> (EventSources, [String: String]) { + return (events, variables.environmentVariables) + } + @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") + public static func buildBlock(_ events: EventSources, + _ components: EnvironmentVariables...) -> (EventSources, [String: String]) { + fatalError() + } + } + } // MARK: Event Source public struct EventSources { - private let eventSources: [Resource] - public init(@EventSourceBuilder _ builder: () -> [Resource]) { - self.eventSources = builder() - } - internal func samEventSources() -> [Resource] { - return self.eventSources - } - // MARK: EventSources DSL code - @resultBuilder - public enum EventSourceBuilder { - public static func buildBlock(_ source: Resource...) -> [Resource< - EventSourceType - >] { - return source.compactMap { $0 } - } - public static func buildExpression(_ expression: HttpApi) -> Resource { - return expression.resource() - } - public static func buildExpression(_ expression: Sqs) -> Resource { - return expression.resource() - } - } + private let eventSources: [Resource] + public init(@EventSourceBuilder _ builder: () -> [Resource]) { + self.eventSources = builder() + } + internal func samEventSources() -> [Resource] { + return self.eventSources + } + // MARK: EventSources DSL code + @resultBuilder + public enum EventSourceBuilder { + public static func buildBlock(_ source: Resource...) -> [Resource] { + return source.compactMap { $0 } + } + public static func buildExpression(_ expression: HttpApi) -> Resource { + return expression.resource() + } + public static func buildExpression(_ expression: Sqs) -> Resource { + return expression.resource() + } + public static func buildExpression(_ expression: Resource) -> Resource { + return expression + } + } } // MARK: HttpApi event source public struct HttpApi { - private let method: HttpVerb? - private let path: String? - public init( - method: HttpVerb? = nil, - path: String? = nil - ) { - self.method = method - self.path = path - } - internal func resource() -> Resource { - return Resource.httpApi(method: self.method, path: self.path) - } + private let method: HttpVerb? + private let path: String? + public init( + method: HttpVerb? = nil, + path: String? = nil + ) { + self.method = method + self.path = path + } + internal func resource() -> Resource { + return Resource.httpApi(method: self.method, path: self.path) + } } // MARK: SQS Event Source public struct Sqs { - private let name: String - private var queueRef: String? = nil - private var queue: Queue? = nil - public init(name: String = "SQSEvent") { - self.name = name - } - public init(name: String = "SQSEvent", _ queue: String) { - self.name = name - self.queueRef = queue - } - public init(name: String = "SQSEvent", _ queue: Queue) { - self.name = name - self.queue = queue - } - 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 { - if self.queue != nil { - return Resource.sqs(name: self.name, queue: self.queue!.resource()) - } else if self.queueRef != nil { - return Resource.sqs(name: self.name, queue: self.queueRef!) - } else { - fatalError("Either queue or queueRef muts have a value") - } - } + private let name: String + private var queueRef: String? = nil + private var queue: Queue? = nil + public init(name: String = "SQSEvent") { + self.name = name + } + public init(name: String = "SQSEvent", _ queue: String) { + self.name = name + self.queueRef = queue + } + public init(name: String = "SQSEvent", _ queue: Queue) { + self.name = name + self.queue = queue + } + 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 { + if self.queue != nil { + return Resource.sqs(name: self.name, queue: self.queue!.resource()) + } else if self.queueRef != nil { + return Resource.sqs(name: self.name, queue: self.queueRef!) + } else { + fatalError("Either queue or queueRef muts have a value") + } + } } // 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 - } - } + + 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 public struct Queue { - let logicalName: String - let physicalName: String - public init(logicalName: String, physicalName: String) { - self.logicalName = logicalName - self.physicalName = physicalName - } - internal func resource() -> Resource { - return Resource.queue( - logicalName: self.logicalName, - physicalName: self.physicalName) - } + let logicalName: String + let physicalName: String + public init(logicalName: String, physicalName: String) { + self.logicalName = logicalName + self.physicalName = physicalName + } + internal func resource() -> Resource { + return Resource.queue( + logicalName: self.logicalName, + physicalName: self.physicalName) + } } // MARK: Table top level resource public struct Table { - let logicalName: String - let physicalName: String - let primaryKeyName: String - let primaryKeyType: String - public init( - logicalName: String, - physicalName: String, - primaryKeyName: String, - primaryKeyType: String - ) { - - self.logicalName = logicalName - self.physicalName = physicalName - self.primaryKeyName = primaryKeyName - self.primaryKeyType = primaryKeyType - } - internal func resource() -> Resource { - return Resource.table( - logicalName: self.logicalName, - physicalName: self.physicalName, - primaryKeyName: self.primaryKeyName, - primaryKeyType: self.primaryKeyType) - } + let logicalName: String + let physicalName: String + let primaryKeyName: String + let primaryKeyType: String + public init( + logicalName: String, + physicalName: String, + primaryKeyName: String, + primaryKeyType: String + ) { + + self.logicalName = logicalName + self.physicalName = physicalName + self.primaryKeyName = primaryKeyName + self.primaryKeyType = primaryKeyType + } + internal func resource() -> Resource { + return Resource.table( + logicalName: self.logicalName, + physicalName: self.physicalName, + primaryKeyName: self.primaryKeyName, + primaryKeyType: self.primaryKeyType) + } } // MARK: Serialization code extension SAMDeploymentDescriptor { - - internal func toJSON(pretty: Bool = true) -> String { - let encoder = JSONEncoder() - if pretty { - encoder.outputFormatting = .prettyPrinted - } - let jsonData = try! encoder.encode(self) - return String(data: jsonData, encoding: .utf8)! - } - - internal func toYAML() -> String { - let yaml = try! YAMLEncoder().encode(self) - return yaml - } + + internal func toJSON(pretty: Bool = true) -> String { + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = .prettyPrinted + } + let jsonData = try! encoder.encode(self) + return String(data: jsonData, encoding: .utf8)! + } + + internal func toYAML() -> String { + let yaml = try! YAMLEncoder().encode(self) + return yaml + } } 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 { - guard let fd = fdopen(fileDesc, "w") else { return } - switch format { - case .json: fputs(deploymentDescriptor.toJSON(), fd) - case .yaml: fputs(deploymentDescriptor.toYAML(), fd) + + 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 { + guard let fd = fdopen(fileDesc, "w") else { return } + switch format { + case .json: fputs(deploymentDescriptor.toJSON(), fd) + case .yaml: fputs(deploymentDescriptor.toYAML(), fd) + } + + fclose(fd) } +} - fclose(fd) - } +// MARK: Support code for unit testing +// Detect when running inside a unit test +// This allows to avoid calling `fatalError()` 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/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift new file mode 100644 index 00000000..66eeee7e --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -0,0 +1,155 @@ +// ===----------------------------------------------------------------------===// +// +// 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 existance of the ZIP file +// the rest is boiler plate code +final class DeploymentDescriptorBuilderTests: XCTestCase { + + private func generateAndTestDeploymentDescriptor(deployment: MockDeploymentDescriptorBuilder, + expected: String) -> Bool { + // when + let samJSON = deployment.toJSON() + + // then + return samJSON.contains(expected) + } + + func testGenericFunction() { + + // given + let expected = """ +{"Description":"A SAM template to deploy a Swift Lambda function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Events":{"HttpApiEvent":{"Type":"HttpApi"}},"AutoPublishAlias":"Live","Handler":"Provided","CodeUri":"ERROR","Environment":{"Variables":{"NAME1":"VALUE1"}},"Runtime":"provided.al2","Architectures":["arm64"]}}},"Transform":"AWS::Serverless-2016-10-31"} +""" + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + eventSource: HttpApi().resource(), + environmentVariable: ["NAME1": "VALUE1"] + ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + + } + + // check wether the builder creates additional queue resources + func testLambdaCreateAdditionalResourceWithName() { + + // given + let expected = """ +"Resources":{"QueueTestQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"test-queue"}} +""" + + let sqsEventSource = Sqs("test-queue").resource() + + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + eventSource: sqsEventSource, + environmentVariable: [:]) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + // check wether the builder creates additional queue resources + func testLambdaCreateAdditionalResourceWithQueue() { + + // given + let expected = """ +"Resources":{"QueueTestQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"test-queue"}} +""" + + let sqsEventSource = Sqs(Queue(logicalName: "QueueTestQueue", + physicalName: "test-queue")).resource() + + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + eventSource: sqsEventSource, + environmentVariable: [:] ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + // check wether the builder detects missing ZIP package + func testLambdaMissingZIPPackage() { + + // given + let expected = """ +"CodeUri":"ERROR" +""" + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + eventSource: HttpApi().resource(), + environmentVariable: ["NAME1": "VALUE1"] ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + + // check wether the builder detects existing packages + func testLambdaExistingZIPPackage() { + + // given + let (tempDir, tempFile) = prepareTemporaryPackageFile() + + let expected = """ +"CodeUri":"\(tempFile)" +""".replacingOccurrences(of: "/", with: "\\/") + + CommandLine.arguments = ["test", "--archive-path", tempDir] + + let testDeployment = MockDeploymentDescriptorBuilder( + withFunction: true, + architecture: .arm64, + eventSource: HttpApi().resource(), + environmentVariable: ["NAME1": "VALUE1"] ) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + + // cleanup + deleteTemporaryPackageFile(tempFile) + } + + private func prepareTemporaryPackageFile() -> (String,String) { + let fm = FileManager.default + let tempDir = fm.temporaryDirectory + let packageDir = MockDeploymentDescriptorBuilder.packageDir() + let packageZip = MockDeploymentDescriptorBuilder.packageZip() + XCTAssertNoThrow(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) + } + + private func deleteTemporaryPackageFile(_ file: String) { + let fm = FileManager.default + XCTAssertNoThrow(try fm.removeItem(atPath: file)) + } + +} diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 7d81b6b2..1e0ba574 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -15,14 +15,13 @@ @testable import AWSLambdaDeploymentDescriptor import XCTest +// this test case tests the generation of the SAM deployment descriptor in JSON final class DeploymentDescriptorTest: XCTestCase { - private func generateAndTestDeploymentDescriptor(deployment: MockDeploymentDescriptor, expected: String) -> Bool { + private func generateAndTestDeploymentDescriptor(deployment: MockDeploymentDescriptor, + expected: String) -> Bool { // when - let samJSON = deployment.deploymentDescriptor.toJSON(pretty: false) - print(samJSON) - print("====") - print(expected) + let samJSON = deployment.toJSON() // then return samJSON.contains(expected) @@ -34,14 +33,14 @@ final class DeploymentDescriptorTest: XCTestCase { let expected = """ {"Description":"A SAM template to deploy a Swift Lambda function","AWSTemplateFormatVersion":"2010-09-09","Resources":{},"Transform":"AWS::Serverless-2016-10-31"} """ - + let testDeployment = MockDeploymentDescriptor(withFunction: false) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } func testLambdaFunctionResource() { - + // given let expected = """ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} @@ -50,9 +49,9 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testLambdaFunctionWithSpecificArchitectures() { - + // given let expected = """ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.x64.rawValue)"]}}} @@ -62,99 +61,99 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testSimpleTableResource() { - + // given let expected = """ "Resources":{"LogicalTestTable":{"Type":"AWS::Serverless::SimpleTable","Properties":{"TableName":"TestTable","PrimaryKey":{"Name":"pk","Type":"String"}}}} """ - + let testDeployment = MockDeploymentDescriptor(withFunction: false, additionalResources: - [.table(logicalName: "LogicalTestTable", - physicalName: "TestTable", - primaryKeyName: "pk", - primaryKeyType: "String")] + [.table(logicalName: "LogicalTestTable", + physicalName: "TestTable", + primaryKeyName: "pk", + primaryKeyType: "String")] ) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testSQSQueueResource() { - + // given let expected = """ "Resources":{"LogicalQueueName":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"queue-name"}}} """ - + let testDeployment = MockDeploymentDescriptor(withFunction: false, additionalResources: - [.queue(name: "LogicalQueueName", - properties: SQSResourceProperties(queueName: "queue-name"))] - + [.queue(name: "LogicalQueueName", + properties: SQSResourceProperties(queueName: "queue-name"))] + ) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testHttpApiEventSourceCatchAll() { - + // given let expected = """ "Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ - + let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .httpApi() ] ) - + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testHttpApiEventSourceSpecific() { - + // given let expected = """ "Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ - + let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .httpApi(method: .GET, path: "/test") ]) - + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } -// + // func testSQSEventSourceWithArn() { - + // given let expected = """ "Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} """ - + let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] ) - + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testSQSEventSourceWithoutArn() { - + // given let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .sqs(queue: "queue-lambda-test") ] ) - + let expected = """ "Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]}}}} """ XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testEnvironmentVariablesString() { - + // given let expectedinOrder = """ "Environment":{"Variables":{"TEST2_VAR":"TEST2_VALUE","TEST1_VAR":"TEST1_VALUE"}} @@ -162,27 +161,27 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty let expectedOutOfOrder = """ "Environment":{"Variables":{"TEST1_VAR":"TEST1_VALUE","TEST2_VAR":"TEST2_VALUE"}} """ - - + + let testDeployment = MockDeploymentDescriptor(withFunction: true, environmentVariable: SAMEnvironmentVariable(["TEST1_VAR": "TEST1_VALUE", "TEST2_VAR": "TEST2_VALUE"]) ) - + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expectedinOrder) || self.generateAndTestDeploymentDescriptor(deployment: testDeployment, - expected: expectedOutOfOrder) + expected: expectedOutOfOrder) ) } - + func testEnvironmentVariablesArray() { - + // given let expected = """ "Environment":{"Variables":{"TEST1_VAR":{"Ref":"TEST1_VALUE"}}} """ - + var envVar = SAMEnvironmentVariable() envVar.append("TEST1_VAR", ["Ref" : "TEST1_VALUE"]) let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -190,14 +189,14 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testEnvironmentVariablesDictionary() { - + // given let expected = """ "Environment":{"Variables":{"TEST1_VAR":{"Fn::GetAtt":["TEST1_VALUE","Arn"]}}} """ - + var envVar = SAMEnvironmentVariable() envVar.append("TEST1_VAR", ["Fn::GetAtt" : ["TEST1_VALUE", "Arn"]]) let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -205,14 +204,14 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testEnvironmentVariablesResource() { - + // given let expected = """ "Environment":{"Variables":{"TEST1_VAR":{"Ref":"LogicalName"}}} """ - + let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") var envVar = SAMEnvironmentVariable() envVar.append("TEST1_VAR", resource) @@ -221,27 +220,27 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + 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) } - + } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index 1979515f..e00c1fde 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -13,10 +13,10 @@ // ===----------------------------------------------------------------------===// import Foundation -import AWSLambdaDeploymentDescriptor +@testable import AWSLambdaDeploymentDescriptor struct MockDeploymentDescriptor { - + let deploymentDescriptor : SAMDeploymentDescriptor init(withFunction: Bool = true, @@ -37,7 +37,7 @@ struct MockDeploymentDescriptor { environment: environmentVariable ?? SAMEnvironmentVariable.none ) ] + (additionalResources ?? []) - + ) } else { self.deploymentDescriptor = SAMDeploymentDescriptor( @@ -46,5 +46,53 @@ struct MockDeploymentDescriptor { ) } } + func toJSON() -> String { + return self.deploymentDescriptor.toJSON(pretty: false) + } +} + +struct MockDeploymentDescriptorBuilder { + + static let functioName = "TestLambda" + let deploymentDescriptor : DeploymentDescriptor + + init(withFunction: Bool = true, + architecture: Architectures = Architectures.defaultArchitecture(), + eventSource: Resource, + environmentVariable: [String:String]) + { + if withFunction { + + self.deploymentDescriptor = DeploymentDescriptor { + "A SAM template to deploy a Swift Lambda function" + + Function(name: MockDeploymentDescriptorBuilder.functioName, + architecture: architecture) { + 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) + } + + static func packageDir() -> String { + return "/\(functioName)" + } + static func packageZip() -> String { + return "/\(functioName).zip" + } } From 56a0d5fc712c4dcd9ce35cde92aeaa74656610a2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 15:52:21 +0100 Subject: [PATCH 45/79] do not overwrite SAM deployment descriptor, unless --force is passed as an option --- Examples/SAM/Makefile | 4 +-- Plugins/AWSLambdaDeployer/Plugin.swift | 47 +++++++++++++++++++------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile index da26da9f..784ad14a 100644 --- a/Examples/SAM/Makefile +++ b/Examples/SAM/Makefile @@ -23,10 +23,10 @@ nodeploy: build swift package --disable-sandbox deploy --verbose --nodeploy --configuration debug localtest: - sam local invoke -t sam.json -e Tests/LambdaTests/data/apiv2.json HttpApiLambda + sam local invoke -t sam.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda list: - sam list endpoints -t sam.json --stack-name swift-aws-lambda-runtime-example --output json + sam list endpoints -t sam.yaml --stack-name swift-aws-lambda-runtime-example --output json clean: # find . -name .build -exec rm -rf {} \; diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 337de09f..3f759116 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -27,7 +27,7 @@ struct AWSLambdaPackager: CommandPlugin { } // gather file paths - let samDeploymentDescriptorFilePath = "\(context.package.directory)/sam.json" + let samDeploymentDescriptorFilePath = "\(context.package.directory)/sam.yaml" let swiftExecutablePath = try self.findExecutable(context: context, executableName: "swift", @@ -45,6 +45,7 @@ struct AWSLambdaPackager: CommandPlugin { swiftExecutable: swiftExecutablePath, samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, archivePath: configuration.archiveDirectory, + force: configuration.force, verboseLogging: configuration.verboseLogging) @@ -76,6 +77,7 @@ struct AWSLambdaPackager: CommandPlugin { swiftExecutable: Path, samDeploymentDescriptorFilePath: String, archivePath: String, + force: Bool, verboseLogging: Bool) throws { print("-------------------------------------------------------------------------") print("Generating SAM deployment descriptor") @@ -131,10 +133,19 @@ struct AWSLambdaPackager: CommandPlugin { // logLevel: configuration.verboseLogging ? .debug : .silent) try FileManager.default.removeItem(atPath: helperFilePath) - // write the generated SAM deployment decsriptor to disk - FileManager.default.createFile(atPath: samDeploymentDescriptorFilePath, - contents: samDeploymentDescriptor.data(using: .utf8)) - verboseLogging ? print("\(samDeploymentDescriptorFilePath)") : nil + // 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("Overwriting file at \(samDeploymentDescriptorFilePath)") : nil + } } catch let error as DeployerPluginError { print("Error while compiling Deploy.swift") @@ -271,23 +282,28 @@ REQUIREMENTS: To use this plugin, you must have an AWS account and have `sam` in 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] [--nodeploy] [--configuration ] [--archive-path ] [--stack-name ] +USAGE: swift package --disable-sandbox deploy [--help] [--verbose] + [--archive-path ] + [--configuration ] + [--force] [--nodeploy] [--nolist] + [--stack-name ] OPTIONS: --verbose Produce verbose output for debugging. - --nodeploy Generates the JSON deployment descriptor, but do not deploy. - --configuration - Build for a specific configuration. - Must be aligned with what was used to build and package. - Valid values: [ debug, release ] (default: debug) --archive-path The path where the archive plugin created the ZIP archive. Must be aligned with the value passed to archive --output-path. (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 JSON 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) - --nolist Do not list endpoints --help Show help information. """) } @@ -298,6 +314,7 @@ private struct Configuration: CustomStringConvertible { 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 @@ -316,6 +333,7 @@ private struct Configuration: CustomStringConvertible { 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") @@ -324,6 +342,9 @@ private struct Configuration: CustomStringConvertible { // help required ? self.help = helpArgument + // force overwrite the SAM deployment descriptor when it already exists + self.force = forceArgument + // define deployment option self.noDeploy = nodeployArgument @@ -358,7 +379,7 @@ private struct Configuration: CustomStringConvertible { "invalid archive directory: \(self.archiveDirectory)\nthe directory does not exists") } - // infer or consume stackname + // infer or consume stack name if let stackName = stackNameArgument.first { self.stackName = stackName } else { From b9a88264c9315217e52e4719141d10ddf3af6fa2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 17:17:24 +0100 Subject: [PATCH 46/79] change order of default values --- Examples/SAM/Deploy.swift | 4 ++-- Examples/SAM/Makefile | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift index b8abbeab..2b7b6cc1 100644 --- a/Examples/SAM/Deploy.swift +++ b/Examples/SAM/Deploy.swift @@ -15,7 +15,7 @@ DeploymentDescriptor { "Description of this deployment descriptor" // a lambda function - Function(name: "HttpApiLambda", architecture: .x64) { + Function(name: "HttpApiLambda") { EventSources { @@ -39,7 +39,7 @@ DeploymentDescriptor { } // create a Lambda function and its depending resources - Function(name: "SQSLambda") { + Function(name: "SQSLambda", architecture: .arm64) { EventSources { diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile index 784ad14a..be26e7b8 100644 --- a/Examples/SAM/Makefile +++ b/Examples/SAM/Makefile @@ -27,6 +27,15 @@ localtest: list: sam list endpoints -t sam.yaml --stack-name swift-aws-lambda-runtime-example --output json + +logs: + sam logs --stack-name swift-aws-lambda-runtime-example + +logstail: + sam logs --stack-name swift-aws-lambda-runtime-example -t + +delete: + sam delete --stack-name swift-aws-lambda-runtime-example clean: # find . -name .build -exec rm -rf {} \; From 483a9beaa0ab656777ac2ac354ca48e1fdd9af5f Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 17:49:59 +0100 Subject: [PATCH 47/79] fix typos in help message --- Plugins/AWSLambdaDeployer/Plugin.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 3f759116..d6bc5347 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -292,15 +292,15 @@ 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. + 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 JSON deployment descriptor, but do not deploy. - --nolist Do not list endpoints + --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) From 44d6f770c12a7d53a4380a8cadc4fd39ffa1fb1d Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 17:50:06 +0100 Subject: [PATCH 48/79] update readme --- Plugins/AWSLambdaDeployer/README.md | 71 ++++++++++++++++------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index 57365268..0da9e633 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -6,13 +6,13 @@ The existing `archive` plugin generates a ZIP to be deployed on AWS. While it re 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 solves this problem. 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 descriptor file and to deploy the infrastructure into the AWS cloud. +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 support a subset of HTTP API Gateway v2 and SQS queues. +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 the Swift programming language by writing a `Deploy.swift` file (similar to `Package.swift` used by SPM). The plugin transform the `Deploy.swift` data structure into a SAM template. It then calls the SAM CLI to validate and to deploy the template. +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: @@ -20,9 +20,9 @@ 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 JSON 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. +- `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 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. +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: @@ -75,19 +75,21 @@ I add the new `Deploy.swift` file at the top of my project. Here is a simple dep ```swift import AWSLambdaDeploymentDescriptor -let _ = DeploymentDefinition( - - functions: [ - .function( - name: "HttpApiLambda", - architecture: .arm64, // optional, defaults to current build platform - eventSources: [ - .httpApi(method: .GET, path: "/test"), - ], - environment: .variable(["LOG_LEVEL":"debug"]) //optional - ) - ] -) +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 add a dependency in my project's `Package.swift`. On a `testTarget`, I add this dependency: @@ -124,7 +126,7 @@ Similarly to the archiver plugin, the deployer plugin must escape the sandbox be 5. (optionally) Swift lambda function developer may also use SAM to test the code locally. ```bash -sam local invoke -t sam.json -e Tests/LambdaTests/data/apiv2.json HttpApiLambda +sam local invoke -t sam.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda ``` ## Command Line Options @@ -133,26 +135,32 @@ 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] [--nodeploy] [--configuration ] [--archive-path ] [--stack-name ] +USAGE: swift package --disable-sandbox deploy [--help] [--verbose] + [--archive-path ] + [--configuration ] + [--force] [--nodeploy] [--nolist] + [--stack-name ] OPTIONS: --verbose Produce verbose output for debugging. - --nodeploy Generates the JSON deployment descriptor, but do not deploy. - --nolist Do not call sam list to list endpoints - --configuration - Build for a specific configuration. - Must be aligned with what was used to build and package. - Valid values : [ debug, release ] (default: debug) --archive-path The path where the archive plugin created the ZIP archive. - Must be aligned with the value passed to archive --output-path. + 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) @@ -165,7 +173,7 @@ OPTIONS: 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 +#### 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. @@ -173,16 +181,15 @@ I assume the typical Lambda function developer knows the Swift programming langu The source code to implement this approach is in the `AWSLambdaDeploymentDescriptor` library. This approach is similar to `Package.swift` used by the Swift Package Manager. -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. +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** -One alternative proposed during early reviews is to use a DSL instead of a programmatic approach. A Swift-based DSL might be implemented using [Result Builders](https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md), available since Swift 5.4. -After having read [tutorials](https://theswiftdev.com/result-builders-in-swift/) and watch a [WWDC 2021 session](https://developer.apple.com/videos/play/wwdc2021/10253/), I understand this might provide Swift Lambda function developer a strongly typed, Swift-friendly, DSL to express their SAM deployment descriptor. I will give a try to this approach and propose it in a PR. +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. -Another alternative is to not use a programmatic 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. +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. From 9d1a2d7af3eaf57427d4bb3c207f6db6f29c5ec5 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 27 Feb 2023 17:53:28 +0100 Subject: [PATCH 49/79] clarifies README --- Plugins/AWSLambdaDeployer/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index 0da9e633..3a7e3d48 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -177,9 +177,9 @@ SAM is already broadly adopted, well maintained and documented. It does the job. 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 struct in a top-level `Deploy.swift` file. The `deploy` plugin dynamically compiles this file to generate the SAM JSON deployment descriptor. +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 approach is similar to `Package.swift` used by the Swift Package Manager. +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. From 87e4f44a4d9c2887411fe49cff9202ba0cc77acc Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 28 Feb 2023 07:20:29 +0100 Subject: [PATCH 50/79] simplify dependencies management --- Examples/SAM/Package.swift | 8 -------- Package.swift | 7 +++++-- Plugins/AWSLambdaDeployer/README.md | 20 ++------------------ 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index be46ea3d..799913de 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -54,14 +54,6 @@ let package = Package( dependencies: [ "HttpApiLambda", "SQSLambda", .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), - - // at least one target must have this dependency. It ensures Swift builds the dylib - // The dylib is loaded dynamically by the deploy plugin when compiling Deploy.swift. - // The Lambda functions or the tests do not actually depend on it. - // I choose to add the dependency on a test target, because the archive plugin - // links the executable targets with --static-swift-stdlib which cannot include this dynamic library, - // and causes the build to fail - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") ], // testing data resources: [ diff --git a/Package.swift b/Package.swift index d0a9b763..fab3bdd7 100644 --- a/Package.swift +++ b/Package.swift @@ -57,7 +57,7 @@ let package = Package( .target( name: "AWSLambdaDeploymentDescriptor", dependencies: [ - .product(name: "Yams", package: "Yams") + .product(name: "Yams", package: "Yams"), ], path: "Sources/AWSLambdaDeploymentDescriptor" ), @@ -69,7 +69,10 @@ let package = Package( 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")] - ) + ), + dependencies: [ + .byName(name: "AWSLambdaDeploymentDescriptor") + ] ), .testTarget(name: "AWSLambdaRuntimeCoreTests", dependencies: [ .byName(name: "AWSLambdaRuntimeCore"), diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index 3a7e3d48..126c38dc 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -92,23 +92,7 @@ DeploymentDescriptor { } ``` -3. I add a dependency in my project's `Package.swift`. On a `testTarget`, I add this dependency: - -```swift - // on the testTarget() - dependencies: [ - // other dependencies - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") - ] -``` - -I also might add this dependency on one of my Lambda functions `executableTarget`. In this case, I make sure it is added only when building on macOS. - -```swift - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) -``` - -4. I invoke the archive plugin and the deploy plugin from the command line. +3. I invoke the archive plugin and the deploy plugin from the command line. ```bash @@ -123,7 +107,7 @@ 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. -5. (optionally) Swift lambda function developer may also use SAM to test the code locally. +4. (optionally) Swift lambda function developer may also use SAM to test the code locally. ```bash sam local invoke -t sam.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda From 675a1778914f2bdd096571d73028e425f575efa7 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 28 Feb 2023 08:05:20 +0100 Subject: [PATCH 51/79] add support for SQS batchsize and enable properties --- .../DeploymentDescriptor.swift | 62 ++++++++++++------- .../DeploymentDescriptorBuilder.swift | 31 ++++++++-- .../DeploymentDescriptorTests.swift | 7 ++- 3 files changed, 72 insertions(+), 28 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 1bd343e8..9e7dd329 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -345,33 +345,34 @@ public enum HttpVerb: String, Encodable { -----------------------------------------------------------------------------------------*/ extension Resource { - internal static func sqs( - name: String = "SQSEvent", - properties: SQSEventProperties - ) -> Resource { + internal static func sqs(name: String = "SQSEvent", properties: SQSEventProperties) -> Resource { return Resource( type: .sqs, properties: properties, name: name) } - public static func sqs( - name: String = "SQSEvent", - queue queueRef: String - ) -> Resource { + public static func sqs(name: String = "SQSEvent", + queue queueRef: String, + batchSize: Int = 10, + enabled: Bool = true) -> Resource { - let properties = SQSEventProperties(byRef: queueRef) + let properties = SQSEventProperties(byRef: queueRef, + batchSize: batchSize, + enabled: enabled) return Resource.sqs( name: name, properties: properties) } - public static func sqs( - name: String = "SQSEvent", - queue: Resource - ) -> Resource { + public static func sqs(name: String = "SQSEvent", + queue: Resource, + batchSize: Int = 10, + enabled: Bool = true) -> Resource { - let properties = SQSEventProperties(queue) + let properties = SQSEventProperties(queue, + batchSize: batchSize, + enabled: enabled) return Resource.sqs( name: name, properties: properties) @@ -384,26 +385,41 @@ public struct SQSEventProperties: SAMResourceProperties, Equatable { public var queueByArn: String? = nil public var queue: Resource? = nil + public var batchSize : Int + public var enabled : Bool - init(byRef ref: String) { + 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)?.arn { self.queueByArn = arn } else { let logicalName = Resource.logicalName( - resourceType: "Queue", - resourceName: ref) + resourceType: "Queue", + resourceName: ref) self.queue = Resource.queue( - name: logicalName, - properties: SQSResourceProperties(queueName: ref)) - } - + name: logicalName, + properties: SQSResourceProperties(queueName: ref)) + } + self.batchSize = batchSize + self.enabled = enabled + } + + init(_ queue: Resource, + batchSize: Int, + enabled: Bool) { + + self.queue = queue + self.batchSize = batchSize + self.enabled = enabled } - init(_ queue: Resource) { self.queue = queue } enum CodingKeys: String, CodingKey { case queue = "Queue" + case batchSize = " BatchSize" + case enabled = "Enabled" } public func encode(to encoder: Encoder) throws { @@ -418,6 +434,8 @@ public struct SQSEventProperties: SAMResourceProperties, Equatable { getAttIntrinsicFunction["Fn::GetAtt"] = [queue.name, "Arn"] try container.encode(getAttIntrinsicFunction, forKey: .queue) } + try container.encode(batchSize, forKey: .batchSize) + try container.encode(enabled, forKey: .enabled) } } diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index 31f81508..b4e71b5f 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -251,16 +251,29 @@ public struct Sqs { private let name: String private var queueRef: String? = nil private var queue: Queue? = nil + 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) { + 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) { + 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) @@ -268,9 +281,15 @@ public struct Sqs { } internal func resource() -> Resource { if self.queue != nil { - return Resource.sqs(name: self.name, queue: self.queue!.resource()) + return Resource.sqs(name: self.name, + queue: self.queue!.resource(), + batchSize: self.batchSize, + enabled: self.enabled) } else if self.queueRef != nil { - return Resource.sqs(name: self.name, queue: self.queueRef!) + return Resource.sqs(name: self.name, + queue: self.queueRef!, + batchSize: self.batchSize, + enabled: self.enabled) } else { fatalError("Either queue or queueRef muts have a value") } @@ -376,6 +395,10 @@ private struct DeploymentDescriptorSerializer { 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) diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 1e0ba574..36f803f8 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -22,6 +22,9 @@ final class DeploymentDescriptorTest: XCTestCase { expected: String) -> Bool { // when let samJSON = deployment.toJSON() + // print(samJSON) + // print("===========") + // print(expected) // then return samJSON.contains(expected) @@ -129,7 +132,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty // given let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} +"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"," BatchSize":10,"Enabled":true}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} """ let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -146,7 +149,7 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty eventSource: [ .sqs(queue: "queue-lambda-test") ] ) let expected = """ -"Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]}}}} +"Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]}," BatchSize":10,"Enabled":true}}} """ XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) From 965d97873e7e9e4fe5a0da22d45d3d003b821ea7 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 14 Mar 2023 11:09:58 +0100 Subject: [PATCH 52/79] fix an error I introduced earlier in packager plugin --- Plugins/AWSLambdaPackager/Plugin.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index e23711d0..dae18f62 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -113,6 +113,7 @@ struct AWSLambdaPackager: CommandPlugin { try Utils.execute( executable: dockerToolPath, arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], + logLevel: verboseLogging ? .debug : .output ) let productPath = buildOutputPath.appending(product.name) guard FileManager.default.fileExists(atPath: productPath.string) else { From 40f02d17420c6a3108abaec57cda1884745f3bf2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 14 Mar 2023 11:10:34 +0100 Subject: [PATCH 53/79] fix path --- Examples/SAM/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile index be26e7b8..7f41a41d 100644 --- a/Examples/SAM/Makefile +++ b/Examples/SAM/Makefile @@ -46,7 +46,7 @@ clean: test-generate: /usr/bin/swift \ - -L .build/debug \ - -I .build/debug \ + -L ../../.build/debug \ + -I ../../.build/debug \ -lAWSLambdaDeploymentDescriptor \ ./Deploy.swift From 560087cacda055451372b1e77ffa50e26f46c251 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 14 Mar 2023 11:11:22 +0100 Subject: [PATCH 54/79] fix to align to PR 292 https://github.com/swift-server/swift-aws-lambda-runtime/pull/292 --- Examples/SAM/Package.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index 799913de..afbb4852 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -14,6 +14,7 @@ // // ===----------------------------------------------------------------------===// +import class Foundation.ProcessInfo // needed for CI to test the local version of the library import PackageDescription let package = Package( @@ -26,10 +27,7 @@ let package = Package( .executable(name: "SQSLambda", targets: ["SQSLambda"]), ], dependencies: [ - // this is the dependency on the swift-aws-lambda-runtime library - // in real-world projects this would say - // .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), - .package(name: "swift-aws-lambda-runtime", path: "../.."), + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") ], targets: [ @@ -63,3 +61,11 @@ let package = Package( ) ] ) + +// 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"), + ] +} \ No newline at end of file From 4d55e5314d29daf34f929786f201831576d929ed Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 14 Mar 2023 14:09:15 +0100 Subject: [PATCH 55/79] refactor unit tests for deployment descriptor --- Package.swift | 5 +- .../DeploymentDescriptor.swift | 2 +- .../DeploymentDecsriptorBase.swift | 126 +++++++++++++++++ .../DeploymentDescriptorBuilderTests.swift | 31 ++-- .../DeploymentDescriptorTests.swift | 132 +++++++++++------- .../MockedDeploymentDescriptor.swift | 8 +- docker/docker-compose.yaml | 3 +- 7 files changed, 225 insertions(+), 82 deletions(-) create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift diff --git a/Package.swift b/Package.swift index fab3bdd7..29794ab8 100644 --- a/Package.swift +++ b/Package.swift @@ -69,10 +69,7 @@ let package = Package( 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")] - ), - dependencies: [ - .byName(name: "AWSLambdaDeploymentDescriptor") - ] + ) ), .testTarget(name: "AWSLambdaRuntimeCoreTests", dependencies: [ .byName(name: "AWSLambdaRuntimeCore"), diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 9e7dd329..0ac86fde 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -418,7 +418,7 @@ public struct SQSEventProperties: SAMResourceProperties, Equatable { enum CodingKeys: String, CodingKey { case queue = "Queue" - case batchSize = " BatchSize" + case batchSize = "BatchSize" case enabled = "Enabled" } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift new file mode 100644 index 00000000..e9dd961e --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift @@ -0,0 +1,126 @@ +// ===----------------------------------------------------------------------===// +// +// 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 { + + func generateAndTestDeploymentDescriptor(deployment: T, + expected: [String]) -> Bool { + // when + let samJSON = deployment.toJSON() + + // then + let result = expected.allSatisfy { samJSON.contains( $0 ) } + + if (!result) { + print("===========") + print(samJSON) + print("-----------") + print(expected.filter{ !samJSON.contains( $0 ) }.compactMap{ $0 }) + print("===========") + } + + return result + } + + func generateAndTestDeploymentDescriptor(deployment: T, + expected: String) -> Bool { + return generateAndTestDeploymentDescriptor(deployment: deployment, expected: [expected]) + } + + func expectedSAMHeaders() -> [String] { + return [""" +"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", codeURI: String = "ERROR") -> [String] { + return [""" +"Resources":{"TestLambda":{ +""", +""" +"Type":"AWS::Serverless::Function" +""", +""" +"AutoPublishAlias":"Live" +""", +""" +"Handler":"Provided" +""", +""" +"CodeUri":"\(codeURI)" +""", +""" +"Runtime":"provided.al2" +""", +""" +"Architectures":["\(architecture)"] +"""] + } + + func expectedEnvironmentVariables() -> [String] { + return [""" +"Environment":{"Variables":{"NAME1":"VALUE1"}} +"""] + } + + func expectedHttpAPi() -> [String] { + return [""" +"HttpApiEvent":{"Type":"HttpApi"} +"""] + } + + func expectedQueue() -> [String] { + return [""" +"Resources": +""", +""" +"QueueTestQueue": +""", +""" +"Type":"AWS::SQS::Queue" +""", +""" +"Properties":{"QueueName":"test-queue"} +"""] + } + + func expectedQueueEventSource(source: String) -> [String] { + return [ +""" +"SQSEvent" +""", +""" +"Type":"SQS" +""", +""" +\(source) +""", +""" +"BatchSize":10 +""", +""" +"Enabled":true +""" + ] + } +} \ No newline at end of file diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift index 66eeee7e..80ef6f65 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -17,25 +17,17 @@ import XCTest // This test case tests the logic built into the DSL, // i.e. the additional resources created automatically -// and the check on existance of the ZIP file +// and the check on existence of the ZIP file // the rest is boiler plate code -final class DeploymentDescriptorBuilderTests: XCTestCase { - - private func generateAndTestDeploymentDescriptor(deployment: MockDeploymentDescriptorBuilder, - expected: String) -> Bool { - // when - let samJSON = deployment.toJSON() - - // then - return samJSON.contains(expected) - } +final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { func testGenericFunction() { // given - let expected = """ -{"Description":"A SAM template to deploy a Swift Lambda function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Events":{"HttpApiEvent":{"Type":"HttpApi"}},"AutoPublishAlias":"Live","Handler":"Provided","CodeUri":"ERROR","Environment":{"Variables":{"NAME1":"VALUE1"}},"Runtime":"provided.al2","Architectures":["arm64"]}}},"Transform":"AWS::Serverless-2016-10-31"} -""" + let expected = [expectedSAMHeaders(), + expectedFunction(), + expectedEnvironmentVariables(), + expectedHttpAPi()].flatMap { $0 } let testDeployment = MockDeploymentDescriptorBuilder( withFunction: true, @@ -53,9 +45,7 @@ final class DeploymentDescriptorBuilderTests: XCTestCase { func testLambdaCreateAdditionalResourceWithName() { // given - let expected = """ -"Resources":{"QueueTestQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"test-queue"}} -""" + let expected = expectedQueue() let sqsEventSource = Sqs("test-queue").resource() @@ -74,9 +64,7 @@ final class DeploymentDescriptorBuilderTests: XCTestCase { func testLambdaCreateAdditionalResourceWithQueue() { // given - let expected = """ -"Resources":{"QueueTestQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"test-queue"}} -""" + let expected = expectedQueue() let sqsEventSource = Sqs(Queue(logicalName: "QueueTestQueue", physicalName: "test-queue")).resource() @@ -106,7 +94,7 @@ final class DeploymentDescriptorBuilderTests: XCTestCase { eventSource: HttpApi().resource(), environmentVariable: ["NAME1": "VALUE1"] ) - XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + XCTAssertTrue(generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -151,5 +139,4 @@ final class DeploymentDescriptorBuilderTests: XCTestCase { let fm = FileManager.default XCTAssertNoThrow(try fm.removeItem(atPath: file)) } - } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 36f803f8..e0ee6b73 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -16,26 +16,13 @@ import XCTest // this test case tests the generation of the SAM deployment descriptor in JSON -final class DeploymentDescriptorTest: XCTestCase { - - private func generateAndTestDeploymentDescriptor(deployment: MockDeploymentDescriptor, - expected: String) -> Bool { - // when - let samJSON = deployment.toJSON() - // print(samJSON) - // print("===========") - // print(expected) - - // then - return samJSON.contains(expected) - } - +final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { + + private var defaultCodeUri = "\\/path\\/lambda.zip" func testSAMHeader() { // given - let expected = """ -{"Description":"A SAM template to deploy a Swift Lambda function","AWSTemplateFormatVersion":"2010-09-09","Resources":{},"Transform":"AWS::Serverless-2016-10-31"} -""" + let expected = expectedSAMHeaders() let testDeployment = MockDeploymentDescriptor(withFunction: false) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, @@ -45,9 +32,8 @@ final class DeploymentDescriptorTest: XCTestCase { func testLambdaFunctionResource() { // given - let expected = """ -function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} -""" + let expected = [expectedFunction(codeURI: defaultCodeUri), expectedSAMHeaders()].flatMap{ $0 } + let testDeployment = MockDeploymentDescriptor(withFunction: true) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) @@ -56,9 +42,10 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testLambdaFunctionWithSpecificArchitectures() { // given - let expected = """ -function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.x64.rawValue)"]}}} -""" + let expected = [expectedFunction(architecture: Architectures.x64.rawValue, codeURI: defaultCodeUri), + expectedSAMHeaders()] + .flatMap{ $0 } + let testDeployment = MockDeploymentDescriptor(withFunction: true, architecture: .x64) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, @@ -68,9 +55,25 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testSimpleTableResource() { // given - let expected = """ -"Resources":{"LogicalTestTable":{"Type":"AWS::Serverless::SimpleTable","Properties":{"TableName":"TestTable","PrimaryKey":{"Name":"pk","Type":"String"}}}} + let expected = [""" +"Resources":{"LogicalTestTable" +""", +""" +"Type":"AWS::Serverless::SimpleTable" +""", +""" +"TableName":"TestTable" +""", +""" +"PrimaryKey" +""", +""" +"Name":"pk" +""", """ +"Type":"String" +"""] + let testDeployment = MockDeploymentDescriptor(withFunction: false, additionalResources: @@ -86,14 +89,12 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testSQSQueueResource() { // given - let expected = """ -"Resources":{"LogicalQueueName":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"queue-name"}}} -""" + let expected = expectedQueue() let testDeployment = MockDeploymentDescriptor(withFunction: false, additionalResources: - [.queue(name: "LogicalQueueName", - properties: SQSResourceProperties(queueName: "queue-name"))] + [.queue(name: "QueueTestQueue", + properties: SQSResourceProperties(queueName: "test-queue"))] ) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, @@ -103,9 +104,11 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testHttpApiEventSourceCatchAll() { // given - let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"HttpApiEvent":{"Type":"HttpApi"}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} -""" + let expected = [expectedSAMHeaders(), + expectedFunction(architecture: Architectures.defaultArchitecture().rawValue, codeURI: defaultCodeUri), +[""" +"HttpApiEvent":{"Type":"HttpApi"} +"""] ].flatMap{ $0 } let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .httpApi() ] ) @@ -117,9 +120,23 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testHttpApiEventSourceSpecific() { // given - let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"HttpApiEvent":{"Type":"HttpApi","Properties":{"Path":"\\/test","Method":"GET"}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["\(Architectures.defaultArchitecture())"]}}} + let expected = [expectedSAMHeaders(), + expectedFunction(architecture: Architectures.defaultArchitecture().rawValue, codeURI: defaultCodeUri), +[""" +{"HttpApiEvent": +""", +""" +"Type":"HttpApi" +""", +""" +"Properties" +""", """ +"Path":"\\/test" +""", +""" +"Method":"GET" +"""] ].flatMap{ $0 } let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .httpApi(method: .GET, path: "/test") ]) @@ -127,16 +144,18 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - // + func testSQSEventSourceWithArn() { + let name = #"arn:aws:sqs:eu-central-1:012345678901:lambda-test"# // given - let expected = """ -"Resources":{"TestLambda":{"Type":"AWS::Serverless::Function","Properties":{"Runtime":"provided.al2","CodeUri":"\\/path\\/lambda.zip","Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":"arn:aws:sqs:eu-central-1:012345678901:lambda-test"," BatchSize":10,"Enabled":true}}},"Handler":"Provided","AutoPublishAlias":"Live","Architectures":["arm64"]}}} -""" + let expected = [ expectedSAMHeaders(), + expectedFunction(codeURI: defaultCodeUri), + expectedQueueEventSource(source: name) + ].flatMap{ $0 } let testDeployment = MockDeploymentDescriptor(withFunction: true, - eventSource: [ .sqs(queue: "arn:aws:sqs:eu-central-1:012345678901:lambda-test") ] ) + eventSource: [ .sqs(queue: name) ] ) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) @@ -144,13 +163,19 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testSQSEventSourceWithoutArn() { + let name = """ + "Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]} + """ + // given let testDeployment = MockDeploymentDescriptor(withFunction: true, eventSource: [ .sqs(queue: "queue-lambda-test") ] ) - let expected = """ -"Events":{"SQSEvent":{"Type":"SQS","Properties":{"Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]}," BatchSize":10,"Enabled":true}}} -""" + let expected = [ expectedSAMHeaders(), + expectedFunction(codeURI: defaultCodeUri), + expectedQueueEventSource(source: name) + ].flatMap{ $0 } + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -158,12 +183,18 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty func testEnvironmentVariablesString() { // given - let expectedinOrder = """ -"Environment":{"Variables":{"TEST2_VAR":"TEST2_VALUE","TEST1_VAR":"TEST1_VALUE"}} + let expected = [""" +"Environment" +""", +""" +"Variables" +""", """ - let expectedOutOfOrder = """ -"Environment":{"Variables":{"TEST1_VAR":"TEST1_VALUE","TEST2_VAR":"TEST2_VALUE"}} +"TEST2_VAR":"TEST2_VALUE" +""", """ +"TEST1_VAR":"TEST1_VALUE" +"""] let testDeployment = MockDeploymentDescriptor(withFunction: true, @@ -171,11 +202,8 @@ function","AWSTemplateFormatVersion":"2010-09-09","Resources":{"TestLambda":{"Ty "TEST2_VAR": "TEST2_VALUE"]) ) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, - expected: expectedinOrder) - || - self.generateAndTestDeploymentDescriptor(deployment: testDeployment, - expected: expectedOutOfOrder) - ) + expected: expected)) + } func testEnvironmentVariablesArray() { diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index e00c1fde..d7b33d2f 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -15,7 +15,11 @@ import Foundation @testable import AWSLambdaDeploymentDescriptor -struct MockDeploymentDescriptor { +protocol MockDeploymentDescriptorBehavior { + func toJSON() -> String +} + +struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { let deploymentDescriptor : SAMDeploymentDescriptor @@ -51,7 +55,7 @@ struct MockDeploymentDescriptor { } } -struct MockDeploymentDescriptorBuilder { +struct MockDeploymentDescriptorBuilder: MockDeploymentDescriptorBehavior { static let functioName = "TestLambda" let deploymentDescriptor : DeploymentDescriptor 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 From f06ce00e33192277dea531c87ae5a270a98f4f87 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 14 Mar 2023 14:15:32 +0100 Subject: [PATCH 56/79] remove makefile --- Examples/SAM/.gitignore | 3 +++ Examples/SAM/Makefile | 52 ----------------------------------------- 2 files changed, 3 insertions(+), 52 deletions(-) create mode 100644 Examples/SAM/.gitignore delete mode 100644 Examples/SAM/Makefile diff --git a/Examples/SAM/.gitignore b/Examples/SAM/.gitignore new file mode 100644 index 00000000..a679167e --- /dev/null +++ b/Examples/SAM/.gitignore @@ -0,0 +1,3 @@ +Makefile +TODO +notes.md \ No newline at end of file diff --git a/Examples/SAM/Makefile b/Examples/SAM/Makefile deleted file mode 100644 index 7f41a41d..00000000 --- a/Examples/SAM/Makefile +++ /dev/null @@ -1,52 +0,0 @@ -build: - swift build - -update: - swift package update - -testlambda: - swift test - -test: - (cd ../.. && swift test --filter AWSLambdaDeploymentDescriptor) - -release: - swift build -c release - -archive: build - swift package --disable-sandbox archive - -deploy: build - swift package --disable-sandbox deploy --configuration debug - -nodeploy: build - swift package --disable-sandbox deploy --verbose --nodeploy --configuration debug - -localtest: - sam local invoke -t sam.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda - -list: - sam list endpoints -t sam.yaml --stack-name swift-aws-lambda-runtime-example --output json - -logs: - sam logs --stack-name swift-aws-lambda-runtime-example - -logstail: - sam logs --stack-name swift-aws-lambda-runtime-example -t - -delete: - sam delete --stack-name swift-aws-lambda-runtime-example - -clean: - # find . -name .build -exec rm -rf {} \; - # find . -name .swiftpm -exec rm -rf {} \; - # find . -name dist -exec rm -rf {} \; - rm -rf ../../.build - rm -rf .build - -test-generate: - /usr/bin/swift \ - -L ../../.build/debug \ - -I ../../.build/debug \ - -lAWSLambdaDeploymentDescriptor \ - ./Deploy.swift From a0d5499c316208014141b6140d89de0822d8b560 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 20 Mar 2023 12:28:53 +0100 Subject: [PATCH 57/79] remove dependency on Yams --- Package.swift | 4 - .../DeploymentDescriptorBuilder.swift | 11 +- .../YAMLEncoder.swift | 1196 ++++++++++++++++ .../YAMLEncoderTests.swift | 1227 +++++++++++++++++ 4 files changed, 2428 insertions(+), 10 deletions(-) create mode 100644 Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift diff --git a/Package.swift b/Package.swift index 29794ab8..7a3277fd 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,6 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.4.2")), .package(url: "https://github.com/swift-server/swift-backtrace.git", .upToNextMajor(from: "1.2.3")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/jpsim/Yams.git", .upToNextMajor(from: "5.0.5")), ], targets: [ .target(name: "AWSLambdaRuntime", dependencies: [ @@ -56,9 +55,6 @@ let package = Package( ), .target( name: "AWSLambdaDeploymentDescriptor", - dependencies: [ - .product(name: "Yams", package: "Yams"), - ], path: "Sources/AWSLambdaDeploymentDescriptor" ), .plugin( diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index b4e71b5f..d529bd85 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -13,17 +13,16 @@ // ===----------------------------------------------------------------------===// import Foundation -import Yams // global state for serialization // This is required because `atexit` can not capture self -private var __deploymentDescriptor: SAMDeploymentDescriptor? +private var _deploymentDescriptor: SAMDeploymentDescriptor? // a top level DeploymentDescriptor DSL @resultBuilder public struct DeploymentDescriptor { - // capture the deployment decsriptor for unit tests + // capture the deployment descriptor for unit tests let samDeploymentDescriptor : SAMDeploymentDescriptor // MARK: Generation of the SAM Deployment Descriptor @@ -38,12 +37,12 @@ public struct DeploymentDescriptor { description: description, resources: resources) // and register it for serialization - __deploymentDescriptor = self.samDeploymentDescriptor + _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) + try! DeploymentDescriptorSerializer.serialize(_deploymentDescriptor!, format: .yaml) } } @@ -378,7 +377,7 @@ extension SAMDeploymentDescriptor { internal func toYAML() -> String { let yaml = try! YAMLEncoder().encode(self) - return yaml + return String(data: yaml, encoding: .utf8)! } } diff --git a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift new file mode 100644 index 00000000..5ca05617 --- /dev/null +++ b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift @@ -0,0 +1,1196 @@ +// ===----------------------------------------------------------------------===// +// +// 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 (in which case it should be exempt from key conversion strategies). +/// +fileprivate 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 + + /// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types. + /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. + /// If the result of the conversion is a duplicate key, then only one value will be present in the result. + case custom((_ codingPath: [CodingKey]) -> CodingKey) + } + + /// 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] = [:] + + /// 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, intValue: nil)) + let encoder = YAMLEncoderImpl(options: self.options, codingPath: elemCodingPath) + + result[key] = 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 .custom(let converter): + return converter(codingPath + [key]) + } + } + + 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 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 * 3)) + } + + 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 ..< nextIndex]) + switch stringBytes[nextIndex] { + case UInt8(ascii: "\""): // quotation mark + bytes.append(contentsOf: [._backslash, ._quote]) + case UInt8(ascii: "\\"): // reverse solidus + bytes.append(contentsOf: [._backslash, ._backslash]) + case 0x08: // backspace + bytes.append(contentsOf: [._backslash, UInt8(ascii: "b")]) + case 0x0C: // form feed + bytes.append(contentsOf: [._backslash, UInt8(ascii: "f")]) + case 0x0A: // line feed + bytes.append(contentsOf: [._backslash, UInt8(ascii: "n")]) + case 0x0D: // carriage return + bytes.append(contentsOf: [._backslash, UInt8(ascii: "r")]) + case 0x09: // tab + bytes.append(contentsOf: [._backslash, UInt8(ascii: "t")]) + default: + func valueToAscii(_ value: UInt8) -> 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 ..< nextIndex]) + bytes.append(contentsOf: [._backslash, UInt8(ascii: "/")]) + nextIndex = stringBytes.index(after: nextIndex) + startCopyIndex = nextIndex + default: + nextIndex = stringBytes.index(after: nextIndex) + } + } + + // copy everything, that hasn't been copied yet + bytes.append(contentsOf: stringBytes[startCopyIndex ..< nextIndex]) + } + } +} + +//===----------------------------------------------------------------------===// +// Shared Key Types +//===----------------------------------------------------------------------===// + +internal struct _YAMLKey: CodingKey { + public var stringValue: String + public var intValue: Int? + + public init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + public init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + public init(stringValue: String, intValue: Int?) { + self.stringValue = stringValue + self.intValue = intValue + } + + internal init(index: Int) { + self.stringValue = "Index \(index)" + self.intValue = index + } + + internal static let `super` = _YAMLKey(stringValue: "super")! +} + +//===----------------------------------------------------------------------===// +// Shared ISO8601 Date Formatter +//===----------------------------------------------------------------------===// + +// NOTE: This value is implicitly lazy and _must_ be lazy. We're compiled against the latest SDK (w/ ISO8601DateFormatter), but linked against whichever Foundation the user has. ISO8601DateFormatter might not exist, so we better not hit this code path on an older OS. +@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) +internal var _iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = .withInternetDateTime + return formatter +}() + +//===----------------------------------------------------------------------===// +// Error Utilities +//===----------------------------------------------------------------------===// + +extension EncodingError { + /// Returns a `.invalidValue` error describing the given invalid floating-point value. + /// + /// + /// - parameter value: The value that was invalid to encode. + /// - parameter path: The path of `CodingKey`s taken to encode this value. + /// - returns: An `EncodingError` with the appropriate path and debug description. + fileprivate static func _invalidFloatingPointValue(_ 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]) +} + +private extension YAMLValue { + var isValue: Bool { + switch self { + case .array, .object: + return false + case .null, .number, .string, .bool: + return true + } + } + + var isContainer: Bool { + switch self { + case .array, .object: + return true + case .null, .number, .string, .bool: + return false + } + } +} + +private extension YAMLValue { + 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/YAMLEncoderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift new file mode 100644 index 00000000..b7ee808e --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift @@ -0,0 +1,1227 @@ +// ===----------------------------------------------------------------------===// +// +// 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(3141592653)) + _testRoundTrip(of: TopLevelArrayWrapper(Timestamp(3141592653))) + } + + 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 { + let _ = try encoder.encode(NestedContainersTestType()) + } catch { + XCTFail("Caught error during encoding nested container types: \(error)") + } + } + + func test_superEncoderCodingPaths() { + let encoder = YAMLEncoder() + do { + let _ = 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(-2000000042), toAndFrom: "-2000000042") + } + + func test_codingOfUInt32() { + test_codingOf(value: UInt32(2000000042), toAndFrom: "2000000042") + } + + func test_codingOfInt64() { +#if !arch(arm) + test_codingOf(value: Int64(-9000000000000000042), toAndFrom: "-9000000000000000042") +#endif + } + + func test_codingOfUInt64() { +#if !arch(arm) + test_codingOf(value: UInt64(9000000000000000042), toAndFrom: "9000000000000000042") +#endif + } + + func test_codingOfInt() { + let intSize = MemoryLayout.size + switch intSize { + case 4: // 32-bit + test_codingOf(value: Int(-2000000042), toAndFrom: "-2000000042") + case 8: // 64-bit +#if arch(arm) + break +#else + test_codingOf(value: Int(-9000000000000000042), 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(2000000042), toAndFrom: "2000000042") + case 8: // 64-bit +#if arch(arm) + break +#else + test_codingOf(value: UInt(9000000000000000042), 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_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 { + let _ = 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 +fileprivate struct EmptyStruct : Codable, Equatable { + static func ==(_ lhs: EmptyStruct, _ rhs: EmptyStruct) -> Bool { + return true + } +} + +fileprivate 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. +fileprivate 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. +fileprivate 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. +fileprivate 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. +fileprivate 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. +fileprivate 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. +fileprivate 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. +fileprivate 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. +fileprivate 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 + } +} + +fileprivate 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 + } +} + +fileprivate 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. +fileprivate 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 + +fileprivate 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_numericLimits", test_numericLimits), + // ("test_snake_case_encoding", test_snake_case_encoding), + // ("test_dictionary_snake_case_decoding", test_dictionary_snake_case_decoding), + // ("test_dictionary_snake_case_encoding", test_dictionary_snake_case_encoding), + ("test_OutputFormattingValues", test_OutputFormattingValues), + ("test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip", test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip), + ] + } +} From a9e17369e346df9c5a241e513eb3ba74ac694104 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 1 Apr 2023 21:40:59 +0800 Subject: [PATCH 58/79] batch of changes after review #2 --- Examples/SAM/.gitignore | 7 +- Examples/SAM/Deploy.swift | 8 +- Examples/SAM/HttpApiLambda/Lambda.swift | 16 +- Examples/SAM/Package.swift | 15 +- Examples/SAM/SQSLambda/Lambda.swift | 10 +- .../Tests/LambdaTests/HttpApiLambdaTest.swift | 2 +- .../SAM/Tests/LambdaTests/LambdaTest.swift | 6 +- .../SAM/Tests/LambdaTests/SQSLambdaTest.swift | 4 +- .../SAM/Tests/LambdaTests/data/apiv2.json | 47 ++- Plugins/AWSLambdaDeployer/Plugin.swift | 211 ++++++++--- Plugins/AWSLambdaDeployer/README.md | 21 +- Plugins/AWSLambdaPackager/Plugin.swift | 4 +- Plugins/AWSLambdaPackager/PluginUtils.swift | 26 +- .../DeploymentDescriptor.swift | 255 ++++---------- .../DeploymentDescriptorBuilder.swift | 197 +++++++---- .../YAMLEncoder.swift | 57 ++- .../DeploymentDecsriptorBase.swift | 126 ------- .../DeploymentDescriptorBase.swift | 188 ++++++++++ .../DeploymentDescriptorBuilderTests.swift | 93 +++-- .../DeploymentDescriptorTests.swift | 329 ++++++++++-------- .../MockedDeploymentDescriptor.swift | 76 ++-- .../YAMLEncoderTests.swift | 71 ++-- 22 files changed, 1006 insertions(+), 763 deletions(-) delete mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift diff --git a/Examples/SAM/.gitignore b/Examples/SAM/.gitignore index a679167e..5970ff59 100644 --- a/Examples/SAM/.gitignore +++ b/Examples/SAM/.gitignore @@ -1,3 +1,8 @@ Makefile TODO -notes.md \ No newline at end of file +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 index 2b7b6cc1..4a4394ae 100644 --- a/Examples/SAM/Deploy.swift +++ b/Examples/SAM/Deploy.swift @@ -30,7 +30,7 @@ DeploymentDescriptor { EnvironmentVariables { [ "NAME1": "VALUE1", - "NAME2": "VALUE2", + "NAME2": "VALUE2" ] // shared environment variables declared upfront @@ -53,7 +53,8 @@ DeploymentDescriptor { // Sqs() // .queue(logicalName: "LambdaQueueResource", physicalName: "swift-lambda-queue-resource") - // // this will reference a shared queue resource created in this deployment descriptor + // 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) } @@ -64,9 +65,6 @@ DeploymentDescriptor { } } - // shared resources declared upfront - sharedQueue - // // additional resources // diff --git a/Examples/SAM/HttpApiLambda/Lambda.swift b/Examples/SAM/HttpApiLambda/Lambda.swift index 463662f9..7deea5e7 100644 --- a/Examples/SAM/HttpApiLambda/Lambda.swift +++ b/Examples/SAM/HttpApiLambda/Lambda.swift @@ -17,7 +17,7 @@ import AWSLambdaRuntime import Foundation @main -struct HttpApiLambda: SimpleLambdaHandler { +struct HttpApiLambda: LambdaHandler { init() {} init(context: LambdaInitializationContext) async throws { context.logger.info( @@ -26,27 +26,27 @@ struct HttpApiLambda: SimpleLambdaHandler { // 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 contronl on the status code and headers, return an APIGatewayV2Response + + // 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 index afbb4852..842ac3dc 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -24,7 +24,7 @@ let package = Package( ], products: [ .executable(name: "HttpApiLambda", targets: ["HttpApiLambda"]), - .executable(name: "SQSLambda", targets: ["SQSLambda"]), + .executable(name: "SQSLambda", targets: ["SQSLambda"]) ], dependencies: [ .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha"), @@ -35,7 +35,7 @@ let package = Package( name: "HttpApiLambda", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") ], path: "./HttpApiLambda" ), @@ -43,8 +43,8 @@ let package = Package( name: "SQSLambda", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - ] , + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], path: "./SQSLambda" ), .testTarget( @@ -52,6 +52,9 @@ let package = Package( dependencies: [ "HttpApiLambda", "SQSLambda", .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), + // This dependency must be included to force Swift to build the deployment descriptor dynamic library + // It can be on any target. But as of Swift 5.8, it can not be added to the plugin target itself 😢 + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") ], // testing data resources: [ @@ -66,6 +69,6 @@ let package = Package( 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"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") ] -} \ No newline at end of file +} diff --git a/Examples/SAM/SQSLambda/Lambda.swift b/Examples/SAM/SQSLambda/Lambda.swift index 87be9335..6e39a418 100644 --- a/Examples/SAM/SQSLambda/Lambda.swift +++ b/Examples/SAM/SQSLambda/Lambda.swift @@ -17,21 +17,21 @@ import AWSLambdaRuntime import Foundation @main -struct SQSLambda: SimpleLambdaHandler { +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 index 344dcc82..590df2d0 100644 --- a/Examples/SAM/Tests/LambdaTests/HttpApiLambdaTest.swift +++ b/Examples/SAM/Tests/LambdaTests/HttpApiLambdaTest.swift @@ -21,7 +21,7 @@ import XCTest class HttpApiLambdaTests: LambdaTest { func testHttpAPiLambda() async throws { - + // given let eventData = try self.loadTestData(file: .apiGatewayV2) let event = try JSONDecoder().decode(APIGatewayV2Request.self, from: eventData) diff --git a/Examples/SAM/Tests/LambdaTests/LambdaTest.swift b/Examples/SAM/Tests/LambdaTests/LambdaTest.swift index 2b7d40d4..dff1ce54 100644 --- a/Examples/SAM/Tests/LambdaTests/LambdaTest.swift +++ b/Examples/SAM/Tests/LambdaTests/LambdaTest.swift @@ -1,7 +1,7 @@ -import Foundation +import Foundation import XCTest -enum TestData : String { +enum TestData: String { case apiGatewayV2 = "apiv2" case sqs = "sqs" } @@ -19,4 +19,4 @@ class LambdaTest: XCTestCase { // load list from file return try Data(contentsOf: urlForTestData(file: file)) } -} \ No newline at end of file +} diff --git a/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift b/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift index e923ca7a..e28b4071 100644 --- a/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift +++ b/Examples/SAM/Tests/LambdaTests/SQSLambdaTest.swift @@ -21,7 +21,7 @@ import XCTest class SQSLambdaTests: LambdaTest { func testSQSLambda() async throws { - + // given let eventData = try self.loadTestData(file: .sqs) let event = try JSONDecoder().decode(SQSEvent.self, from: eventData) @@ -37,4 +37,4 @@ class SQSLambdaTests: LambdaTest { // SQS Lambda returns Void } -} \ No newline at end of file +} diff --git a/Examples/SAM/Tests/LambdaTests/data/apiv2.json b/Examples/SAM/Tests/LambdaTests/data/apiv2.json index c6c61674..d78908c3 100644 --- a/Examples/SAM/Tests/LambdaTests/data/apiv2.json +++ b/Examples/SAM/Tests/LambdaTests/data/apiv2.json @@ -1 +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":"90.103.90.59","path":"\/test","method":"GET"},"domainName":"x6v980zzkh.execute-api.eu-central-1.amazonaws.com","accountId":"486652066693","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":"90.103.90.59","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"} +{ + "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/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index d6bc5347..fb692e34 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -17,7 +17,7 @@ import Foundation import PackagePlugin @main -struct AWSLambdaPackager: CommandPlugin { +struct AWSLambdaDeployer: CommandPlugin { func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { let configuration = try Configuration(context: context, arguments: arguments) @@ -27,7 +27,7 @@ struct AWSLambdaPackager: CommandPlugin { } // gather file paths - let samDeploymentDescriptorFilePath = "\(context.package.directory)/sam.yaml" + let samDeploymentDescriptorFilePath = "\(context.package.directory)/template.yaml" let swiftExecutablePath = try self.findExecutable(context: context, executableName: "swift", @@ -36,29 +36,48 @@ struct AWSLambdaPackager: CommandPlugin { let samExecutablePath = try self.findExecutable(context: context, executableName: "sam", - helpMessage: "Is SAM installed ? (brew tap aws/tap && brew install aws-sam-cli)", + 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) + // 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, - samDeploymentDescriptorFilePath: samDeploymentDescriptorFilePath, - stackName : configuration.stackName, + buildConfiguration: configuration.buildConfiguration, verboseLogging: configuration.verboseLogging) } @@ -75,8 +94,9 @@ struct AWSLambdaPackager: CommandPlugin { private func generateDeploymentDescriptor(projectDirectory: Path, buildConfiguration: PackageManager.BuildConfiguration, swiftExecutable: Path, + shellExecutable: Path, samDeploymentDescriptorFilePath: String, - archivePath: String, + archivePath: String?, force: Bool, verboseLogging: Bool) throws { print("-------------------------------------------------------------------------") @@ -98,14 +118,16 @@ struct AWSLambdaPackager: CommandPlugin { } do { - let cmd = [ - swiftExecutable.string, + var cmd = [ + "\"\(swiftExecutable.string)\"", "-L \(projectDirectory)/.build/\(buildConfiguration)/", "-I \(projectDirectory)/.build/\(buildConfiguration)/", "-l\(sharedLibraryName)", - deploymentDescriptorFilePath, - "--archive-path", archivePath + "\"\(deploymentDescriptorFilePath)\"" ] + if let archive = archivePath { + cmd = cmd + ["--archive-path", archive] + } let helperCmd = cmd.joined(separator: " \\\n") if verboseLogging { @@ -116,22 +138,24 @@ struct AWSLambdaPackager: CommandPlugin { } // create and execute a plugin helper to run the "swift" command - let helperFilePath = "\(projectDirectory)/compile.sh" + 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: Path("/bin/bash"), + executable: shellExecutable, arguments: ["-c", helperFilePath], customWorkingDirectory: projectDirectory, logLevel: verboseLogging ? .debug : .silent) - // running the swift command directly from the plugin does not work 🤷‍♂️ - // let samDeploymentDescriptor = try execute( - // executable: swiftExecutable.path, - // arguments: Array(cmd.dropFirst()), - // customWorkingDirectory: context.package.directory, - // logLevel: configuration.verboseLogging ? .debug : .silent) - try FileManager.default.removeItem(atPath: helperFilePath) + // 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 { @@ -144,7 +168,7 @@ struct AWSLambdaPackager: CommandPlugin { FileManager.default.createFile(atPath: samDeploymentDescriptorFilePath, contents: samDeploymentDescriptor.data(using: .utf8)) - verboseLogging ? print("Overwriting file at \(samDeploymentDescriptorFilePath)") : nil + verboseLogging ? print("Writing file at \(samDeploymentDescriptorFilePath)") : nil } } catch let error as DeployerPluginError { @@ -205,15 +229,44 @@ struct AWSLambdaPackager: CommandPlugin { } } + 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, - samDeploymentDescriptorFilePath: String, - stackName: String, + buildConfiguration: PackageManager.BuildConfiguration, verboseLogging: Bool) throws { - //TODO: check if there is a samconfig.toml file. - // when there is no file, generate one with default data or data collected from params - - print("-------------------------------------------------------------------------") print("Deploying AWS Lambda function") print("-------------------------------------------------------------------------") @@ -222,9 +275,7 @@ struct AWSLambdaPackager: CommandPlugin { try Utils.execute( executable: samExecutablePath, arguments: ["deploy", - "-t", samDeploymentDescriptorFilePath, - "--stack-name", stackName, - "--capabilities", "CAPABILITY_IAM", + "--config-env", buildConfiguration.rawValue, "--resolve-s3"], logLevel: verboseLogging ? .debug : .silent) } catch let error as DeployerPluginError { @@ -252,10 +303,6 @@ struct AWSLambdaPackager: CommandPlugin { stackName: String, verboseLogging: Bool) throws -> String { - //TODO: check if there is a samconfig.toml file. - // when there is no file, generate one with default data or data collected from params - - print("-------------------------------------------------------------------------") print("Listing AWS endpoints") print("-------------------------------------------------------------------------") @@ -274,6 +321,69 @@ struct AWSLambdaPackager: CommandPlugin { } } + + /// 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) { + + // 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! + } + } + + throw DeployerPluginError.noRegionFound(helpMsg) + } + private func displayHelpMessage() { print(""" OVERVIEW: A swift plugin to deploy your Lambda function on your AWS account. @@ -286,6 +396,7 @@ USAGE: swift package --disable-sandbox deploy [--help] [--verbose] [--archive-path ] [--configuration ] [--force] [--nodeploy] [--nolist] + [--region ] [--stack-name ] OPTIONS: @@ -304,6 +415,8 @@ OPTIONS: --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. """) } @@ -316,8 +429,9 @@ private struct Configuration: CustomStringConvertible { public let noList: Bool public let force: Bool public let verboseLogging: Bool - public let archiveDirectory: String + public let archiveDirectory: String? public let stackName: String + public let region: String? private let context: PluginContext @@ -337,6 +451,7 @@ private struct Configuration: CustomStringConvertible { 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 ? @@ -370,13 +485,15 @@ private struct Configuration: CustomStringConvertible { // 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 = "\(context.package.directory.string)/.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/" - } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: self.archiveDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { - throw DeployerPluginError.invalidArgument( - "invalid archive directory: \(self.archiveDirectory)\nthe directory does not exists") + self.archiveDirectory = nil } // infer or consume stack name @@ -386,6 +503,12 @@ private struct Configuration: CustomStringConvertible { self.stackName = context.package.displayName } + if let region = regionArgument.first { + self.region = region + } else { + self.region = nil + } + if self.verboseLogging { print("-------------------------------------------------------------------------") print("configuration") @@ -398,12 +521,13 @@ private struct Configuration: CustomStringConvertible { """ { verbose: \(self.verboseLogging) + force: \(self.force) noDeploy: \(self.noDeploy) noList: \(self.noList) buildConfiguration: \(self.buildConfiguration) - archiveDirectory: \(self.archiveDirectory) + 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) } @@ -415,6 +539,7 @@ private enum DeployerPluginError: Error, CustomStringConvertible { case invalidArgument(String) case toolNotFound(String) case deployswiftDoesNotExist + case noRegionFound(String) case error(Error) var description: String { @@ -425,6 +550,8 @@ private enum DeployerPluginError: Error, CustomStringConvertible { 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/README.md b/Plugins/AWSLambdaDeployer/README.md index 126c38dc..e6e4f979 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -92,6 +92,22 @@ DeploymentDescriptor { } ``` +3. I add a dependency in my project's `Package.swift`. On a `testTarget`, I add this dependency: + +```swift + // on the testTarget + dependencies: [ + // other dependencies + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") + ] +``` + +I also might add this dependency on one of my Lambda functions `executableTarget`. In this case, I make sure it is added only when building on macOS. + +```swift + .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) +``` + 3. I invoke the archive plugin and the deploy plugin from the command line. ```bash @@ -110,7 +126,7 @@ Similarly to the archiver plugin, the deployer plugin must escape the sandbox be 4. (optionally) Swift lambda function developer may also use SAM to test the code locally. ```bash -sam local invoke -t sam.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda +sam local invoke -t template.yaml -e Tests/LambdaTests/data/apiv2.json HttpApiLambda ``` ## Command Line Options @@ -130,6 +146,7 @@ USAGE: swift package --disable-sandbox deploy [--help] [--verbose] [--archive-path ] [--configuration ] [--force] [--nodeploy] [--nolist] + [--region ] [--stack-name ] OPTIONS: @@ -148,6 +165,8 @@ OPTIONS: --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. ``` diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index dae18f62..ed17d1e3 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,7 +10,7 @@ // // SPDX-License-Identifier: Apache-2.0 // -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// import Foundation import PackagePlugin diff --git a/Plugins/AWSLambdaPackager/PluginUtils.swift b/Plugins/AWSLambdaPackager/PluginUtils.swift index b6de81ca..b53addfd 100644 --- a/Plugins/AWSLambdaPackager/PluginUtils.swift +++ b/Plugins/AWSLambdaPackager/PluginUtils.swift @@ -1,4 +1,4 @@ -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // // This source file is part of the SwiftAWSLambdaRuntime open source project // @@ -10,7 +10,7 @@ // // SPDX-License-Identifier: Apache-2.0 // -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// import Dispatch import Foundation @@ -27,22 +27,22 @@ struct Utils { 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 @@ -52,10 +52,10 @@ struct Utils { fflush(stdout) } } - + let pipe = Pipe() pipe.fileHandleForReading.readabilityHandler = { fileHandle in outputQueue.async { outputHandler(fileHandle.availableData) } } - + let process = Process() process.standardOutput = pipe process.standardError = pipe @@ -69,13 +69,13 @@ struct Utils { 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 { @@ -84,7 +84,7 @@ struct Utils { } throw ProcessError.processFailed([executable.string] + arguments, process.terminationStatus, output) } - + return output } diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 0ac86fde..d1b00008 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -21,22 +21,24 @@ import Foundation // 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 = "A SAM template to deploy a Swift Lambda function", 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 = "Transform" @@ -49,11 +51,22 @@ public protocol SAMResource: Encodable {} public protocol SAMResourceType: Encodable, Equatable {} public protocol SAMResourceProperties: Encodable {} -public enum ResourceType: String, SAMResourceType { - case serverlessFunction = "AWS::Serverless::Function" - case queue = "AWS::SQS::Queue" - case table = "AWS::Serverless::SimpleTable" +public enum ResourceType: SAMResourceType { + + case type(_ name: String) + + static var serverlessFunction: Self { .type("AWS::Serverless::Function") } + static var queue: Self { .type("AWS::SQS::Queue") } + static var table: Self { .type("AWS::Serverless::SimpleTable") } + + public func encode(to encoder: Encoder) throws { + if case let .type(value) = self { + var container = encoder.singleValueContainer() + try? container.encode(value) + } + } } + public enum EventSourceType: String, SAMResourceType { case httpApi = "HttpApi" case sqs = "SQS" @@ -61,20 +74,20 @@ public enum EventSourceType: String, SAMResourceType { // generic type to represent either a top-level resource or an event source public struct Resource: SAMResource, Equatable { - + 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: String, CodingKey { case type = "Type" case properties = "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) @@ -85,38 +98,18 @@ public struct Resource: SAMResource, Equatable { } } -//MARK: Lambda Function resource definition +// MARK: Lambda Function resource definition + /*--------------------------------------------------------------------------------------- Lambda Function https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html -----------------------------------------------------------------------------------------*/ -extension Resource { - public static func serverlessFunction( - name: String, - architecture: Architectures, - codeUri: String?, - eventSources: [Resource] = [], - environment: SAMEnvironmentVariable = .none - ) -> Resource { - - let properties = ServerlessFunctionProperties( - codeUri: codeUri, - architecture: architecture, - eventSources: eventSources, - environment: environment) - return Resource( - type: .serverlessFunction, - properties: properties, - name: name) - } -} - 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) @@ -125,7 +118,7 @@ public enum Architectures: String, Encodable, CaseIterable { return .x64 #endif } - + // valid values for error and help message public static func validValues() -> String { return Architectures.allCases.map { $0.rawValue }.joined(separator: ", ") @@ -140,14 +133,14 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { let autoPublishAlias: String var eventSources: [String: Resource] var environment: SAMEnvironmentVariable - + public init( codeUri: String?, architecture: Architectures, eventSources: [Resource] = [], environment: SAMEnvironmentVariable = .none ) { - + self.architectures = [architecture] self.handler = "Provided" self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 @@ -155,12 +148,12 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { self.codeUri = codeUri self.eventSources = [:] self.environment = environment - + for es in eventSources { self.eventSources[es.name] = es } } - + // custom encoding to not provide Environment variables when there is none public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -176,7 +169,7 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { try container.encode(self.environment, forKey: .environment) } } - + enum CodingKeys: String, CodingKey { case architectures = "Architectures" case handler = "Handler" @@ -194,7 +187,7 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { LOG_LEVEL: debug */ public struct SAMEnvironmentVariable: Encodable { - + public var variables: [String: SAMEnvironmentVariableValue] = [:] public init() {} public init(_ variables: [String: String]) { @@ -203,7 +196,7 @@ public struct SAMEnvironmentVariable: Encodable { } } public static var none: SAMEnvironmentVariable { return SAMEnvironmentVariable([:]) } - + public static func variable(_ name: String, _ value: String) -> SAMEnvironmentVariable { return SAMEnvironmentVariable([name: value]) } @@ -211,18 +204,18 @@ public struct SAMEnvironmentVariable: Encodable { 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) } @@ -235,15 +228,15 @@ public struct SAMEnvironmentVariable: Encodable { public mutating func append(_ key: String, _ value: Resource) { variables[key] = .array(value: ["Ref": value.name]) } - + enum CodingKeys: String, CodingKey { case variables = "Variables" } - + public func encode(to encoder: Encoder) throws { 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): @@ -257,58 +250,39 @@ public struct SAMEnvironmentVariable: Encodable { } } } - + 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]]) } - - private 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 +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 -----------------------------------------------------------------------------------------*/ -extension Resource { - public static func httpApi( - name: String = "HttpApiEvent", - method: HttpVerb? = nil, - path: String? = nil - ) -> Resource { - - var properties: SAMResourceProperties? = nil - if method != nil || path != nil { - properties = HttpApiProperties(method: method, path: path) - } - - return Resource( - type: .httpApi, - properties: properties, - name: name) - } -} - struct HttpApiProperties: SAMResourceProperties, Equatable { init(method: HttpVerb? = nil, path: String? = nil) { self.method = method @@ -325,7 +299,7 @@ struct HttpApiProperties: SAMResourceProperties, Equatable { } let method: HttpVerb? let path: String? - + enum HttpApiKeys: String, CodingKey { case method = "Method" case path = "Path" @@ -337,61 +311,26 @@ public enum HttpVerb: String, Encodable { case POST } -//MARK: SQS event definition +// MARK: SQS event definition /*--------------------------------------------------------------------------------------- SQS Event https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html -----------------------------------------------------------------------------------------*/ -extension Resource { - internal static func sqs(name: String = "SQSEvent", properties: SQSEventProperties) -> Resource { - - return Resource( - type: .sqs, - properties: properties, - name: name) - } - public static func sqs(name: String = "SQSEvent", - queue queueRef: String, - batchSize: Int = 10, - enabled: Bool = true) -> Resource { - - let properties = SQSEventProperties(byRef: queueRef, - batchSize: batchSize, - enabled: enabled) - return Resource.sqs( - name: name, - properties: properties) - } - - public static func sqs(name: String = "SQSEvent", - queue: Resource, - batchSize: Int = 10, - enabled: Bool = true) -> Resource { - - let properties = SQSEventProperties(queue, - batchSize: batchSize, - enabled: enabled) - return Resource.sqs( - name: name, - properties: properties) - } -} - /// 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 queueByArn: String? = nil - public var queue: Resource? = nil - public var batchSize : Int - public var enabled : Bool - + + public var queueByArn: String? + public var queue: Resource? + 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)?.arn { self.queueByArn = arn @@ -399,32 +338,32 @@ public struct SQSEventProperties: SAMResourceProperties, Equatable { let logicalName = Resource.logicalName( resourceType: "Queue", resourceName: ref) - self.queue = Resource.queue( - name: logicalName, - properties: SQSResourceProperties(queueName: ref)) - } + self.queue = Resource(type: .queue, + properties: SQSResourceProperties(queueName: ref), + name: logicalName) + } self.batchSize = batchSize self.enabled = enabled } init(_ queue: Resource, batchSize: Int, - enabled: Bool) { + enabled: Bool) { self.queue = queue self.batchSize = batchSize self.enabled = enabled } - + enum CodingKeys: String, CodingKey { case queue = "Queue" case batchSize = "BatchSize" case enabled = "Enabled" } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - + // 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 if let queueByArn { @@ -439,34 +378,13 @@ public struct SQSEventProperties: SAMResourceProperties, Equatable { } } -//MARK: SQS queue resource definition +// MARK: SQS queue resource definition /*--------------------------------------------------------------------------------------- SQS Queue Resource Documentation https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html -----------------------------------------------------------------------------------------*/ -extension Resource { - internal static func queue( - name: String, - properties: SQSResourceProperties - ) -> Resource { - - return Resource( - type: .queue, - properties: properties, - name: name) - } - - public static func queue( - logicalName: String, - physicalName: String - ) -> Resource { - - let sqsProperties = SQSResourceProperties(queueName: physicalName) - return queue(name: logicalName, properties: sqsProperties) - } -} public struct SQSResourceProperties: SAMResourceProperties { public let queueName: String @@ -475,7 +393,7 @@ public struct SQSResourceProperties: SAMResourceProperties { } } -//MARK: Simple DynamoDB table resource definition +// MARK: Simple DynamoDB table resource definition /*--------------------------------------------------------------------------------------- Simple DynamoDB Table Resource @@ -483,29 +401,6 @@ public struct SQSResourceProperties: SAMResourceProperties { https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-simpletable.html -----------------------------------------------------------------------------------------*/ -extension Resource { - internal static func table( - name: String, - properties: SimpleTableProperties - ) -> Resource { - - return Resource( - type: .table, - properties: properties, - name: name) - } - public static func table( - logicalName: String, - physicalName: String, - primaryKeyName: String, - primaryKeyType: String - ) -> Resource { - let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, type: primaryKeyType) - let properties = SimpleTableProperties(primaryKey: primaryKey, tableName: physicalName) - return table(name: logicalName, properties: properties) - } -} - public struct SimpleTableProperties: SAMResourceProperties { let primaryKey: PrimaryKey let tableName: String @@ -541,7 +436,7 @@ public struct SimpleTableProperties: SAMResourceProperties { } } -//MARK: Utils +// MARK: Utils struct Arn { public let arn: String diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index d529bd85..c13ba60c 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -21,33 +21,34 @@ private var _deploymentDescriptor: SAMDeploymentDescriptor? // a top level DeploymentDescriptor DSL @resultBuilder public struct DeploymentDescriptor { - + // capture the deployment descriptor for unit tests - let samDeploymentDescriptor : SAMDeploymentDescriptor - + let samDeploymentDescriptor: SAMDeploymentDescriptor + // MARK: Generation of the SAM Deployment Descriptor - + private init( description: String, resources: [Resource] ) { - - // create SAM deployment descriptor + self.samDeploymentDescriptor = SAMDeploymentDescriptor( description: description, - resources: resources) + 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) { @@ -84,84 +85,104 @@ public struct DeploymentDescriptor { } // MARK: Function resource + public struct Function { let name: String let architecture: Architectures + let codeURI: String? let eventSources: [Resource] let environment: [String: String] - + + 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, architecture: Architectures = Architectures.defaultArchitecture(), + codeURI: String?, eventSources: [Resource] = [], environment: [String: String] = [:] ) { self.name = name self.architecture = architecture + self.codeURI = codeURI self.eventSources = eventSources self.environment = environment } public init( name: String, architecture: Architectures = Architectures.defaultArchitecture(), + codeURI: String? = nil, @FunctionBuilder _ builder: () -> (EventSources, [String: String]) ) { - + let (eventSources, environmentVariables) = builder() let samEventSource: [Resource] = eventSources.samEventSources() self.init( name: name, architecture: architecture, + codeURI: codeURI, eventSources: samEventSource, environment: environmentVariables) } - + // this method fails when the package does not exist at path internal func resources() -> [Resource] { - - let functionResource = [ - Resource.serverlessFunction( - name: self.name, - architecture: self.architecture, - codeUri: packagePath(), - eventSources: self.eventSources, - environment: SAMEnvironmentVariable(self.environment)) - ] + + let properties = ServerlessFunctionProperties( + codeUri: try! packagePath(), + architecture: self.architecture, + eventSources: self.eventSources, + environment: SAMEnvironmentVariable(self.environment)) + + let functionResource = [Resource( + type: .serverlessFunction, + properties: properties, + name: name)] + let additionalQueueResources = collectQueueResources() - + return functionResource + additionalQueueResources } - + // compute the path for the lambda archive - private func packagePath() -> String { - + // 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 + public func packagePath() 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/\(self.name)/\(self.name).zip" + if let path = self.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)/\(self.name)/\(self.name).zip" } } - + // check the ZIP file exists if !FileManager.default.fileExists(atPath: lambdaPackage) { - let msg = "Error: package does not exist at path: \(lambdaPackage)" - - // when running in a unit test, return a marker value - // otherwise, fail abruptly - if !Thread.current.isRunningXCTest { - fatalError(msg) - } else { - lambdaPackage = "ERROR" - } + 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 @@ -181,10 +202,13 @@ public struct Function { sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue } } - + // MARK: Function DSL code @resultBuilder public enum FunctionBuilder { + public static func buildBlock(_ events: EventSources) -> (EventSources, [String: String]) { + return (events, [:]) + } public static func buildBlock(_ events: EventSources, _ variables: EnvironmentVariables) -> (EventSources, [String: String]) { return (events, variables.environmentVariables) @@ -199,7 +223,7 @@ public struct Function { fatalError() } } - + } // MARK: Event Source @@ -233,6 +257,7 @@ public struct EventSources { public struct HttpApi { private let method: HttpVerb? private let path: String? + private let name: String = "HttpApiEvent" public init( method: HttpVerb? = nil, path: String? = nil @@ -241,17 +266,26 @@ public struct HttpApi { self.path = path } internal func resource() -> Resource { - return Resource.httpApi(method: self.method, path: self.path) + + var properties: SAMResourceProperties? + if self.method != nil || self.path != nil { + properties = HttpApiProperties(method: method, path: path) + } + + return Resource( + type: .httpApi, + properties: properties, + name: name) } } // MARK: SQS Event Source public struct Sqs { private let name: String - private var queueRef: String? = nil - private var queue: Queue? = nil - public var batchSize : Int = 10 - public var enabled : Bool = true + 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 @@ -279,36 +313,42 @@ public struct Sqs { return Sqs(name: self.name, queue) } internal func resource() -> Resource { + var properties: SQSEventProperties! = nil if self.queue != nil { - return Resource.sqs(name: self.name, - queue: self.queue!.resource(), - batchSize: self.batchSize, - enabled: self.enabled) + properties = SQSEventProperties(self.queue!.resource(), + batchSize: self.batchSize, + enabled: self.enabled) + } else if self.queueRef != nil { - return Resource.sqs(name: self.name, - queue: self.queueRef!, - batchSize: self.batchSize, - enabled: self.enabled) + + properties = SQSEventProperties(byRef: self.queueRef!, + batchSize: batchSize, + enabled: enabled) } else { fatalError("Either queue or queueRef muts have a value") } + + return Resource( + type: .sqs, + properties: properties, + name: 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] = [:] @@ -329,9 +369,13 @@ public struct Queue { self.physicalName = physicalName } internal func resource() -> Resource { - return Resource.queue( - logicalName: self.logicalName, - physicalName: self.physicalName) + + let properties = SQSResourceProperties(queueName: self.physicalName) + + return Resource( + type: .queue, + properties: properties, + name: self.logicalName) } } @@ -347,34 +391,39 @@ public struct Table { primaryKeyName: String, primaryKeyType: String ) { - + self.logicalName = logicalName self.physicalName = physicalName self.primaryKeyName = primaryKeyName self.primaryKeyType = primaryKeyType } internal func resource() -> Resource { - return Resource.table( - logicalName: self.logicalName, - physicalName: self.physicalName, - primaryKeyName: self.primaryKeyName, - primaryKeyType: self.primaryKeyType) + + let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, + type: primaryKeyType) + let properties = SimpleTableProperties(primaryKey: primaryKey, + tableName: physicalName) + return Resource( + type: .table, + properties: properties, + name: logicalName) } } // MARK: Serialization code extension SAMDeploymentDescriptor { - + internal func toJSON(pretty: Bool = true) -> String { let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] if pretty { - encoder.outputFormatting = .prettyPrinted + encoder.outputFormatting = [encoder.outputFormatting, .prettyPrinted] } let jsonData = try! encoder.encode(self) return String(data: jsonData, encoding: .utf8)! } - + internal func toYAML() -> String { let yaml = try! YAMLEncoder().encode(self) return String(data: yaml, encoding: .utf8)! @@ -382,19 +431,19 @@ extension SAMDeploymentDescriptor { } 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 } @@ -403,14 +452,14 @@ private struct DeploymentDescriptorSerializer { 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()` when unit testing +// 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 { diff --git a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift index 5ca05617..10e9c4bb 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift @@ -15,18 +15,18 @@ // This is based on Foundation's JSONEncoder // https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift -import Foundation +import Foundation /// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary` /// containing `Encodable` values (in which case it should be exempt from key conversion strategies). /// -fileprivate protocol _YAMLStringDictionaryEncodableMarker { } +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 { @@ -47,7 +47,7 @@ open class YAMLEncoder { /// 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, @@ -131,6 +131,9 @@ open class YAMLEncoder { /// 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 @@ -166,7 +169,7 @@ open class YAMLEncoder { let value: YAMLValue = try encodeAsYAMLValue(value) let writer = YAMLValue.Writer(options: self.outputFormatting) let bytes = writer.writeValue(value) - + return Data(bytes) } @@ -633,8 +636,7 @@ private struct YAMLKeyedEncodingContainer: KeyedEncodingContainerP } mutating func nestedContainer(keyedBy _: NestedKey.Type, forKey key: Self.Key) -> - KeyedEncodingContainer where NestedKey: CodingKey - { + KeyedEncodingContainer where NestedKey: CodingKey { let convertedKey = self._converted(key) let newPath = self.codingPath + [convertedKey] let object = self.object.setObject(for: convertedKey.stringValue) @@ -768,8 +770,7 @@ private struct YAMLUnkeyedEncodingContainer: UnkeyedEncodingContainer, _SpecialT } mutating func nestedContainer(keyedBy _: NestedKey.Type) -> - KeyedEncodingContainer where NestedKey: CodingKey - { + KeyedEncodingContainer 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) @@ -917,7 +918,7 @@ extension YAMLValue { } private func addInset(to bytes: inout [UInt8], depth: Int) { - bytes.append(contentsOf: [UInt8](repeating: ._space, count: depth * 3)) + bytes.append(contentsOf: [UInt8](repeating: ._space, count: depth * YAMLEncoder.singleIndent)) } private func writeValuePretty(_ value: YAMLValue, into bytes: inout [UInt8], depth: Int = 0) { @@ -956,8 +957,7 @@ extension YAMLValue { } private func writePrettyObject(_ object: Object, into bytes: inout [UInt8], depth: Int = 0) - where Object.Element == (key: String, value: YAMLValue) - { + where Object.Element == (key: String, value: YAMLValue) { var iterator = object.makeIterator() while let (key, value) = iterator.next() { @@ -1045,9 +1045,9 @@ extension YAMLValue { } } -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // Shared Key Types -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// internal struct _YAMLKey: CodingKey { public var stringValue: String @@ -1076,9 +1076,9 @@ internal struct _YAMLKey: CodingKey { internal static let `super` = _YAMLKey(stringValue: "super")! } -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // Shared ISO8601 Date Formatter -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // NOTE: This value is implicitly lazy and _must_ be lazy. We're compiled against the latest SDK (w/ ISO8601DateFormatter), but linked against whichever Foundation the user has. ISO8601DateFormatter might not exist, so we better not hit this code path on an older OS. @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) @@ -1088,9 +1088,9 @@ internal var _iso8601Formatter: ISO8601DateFormatter = { return formatter }() -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // Error Utilities -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// extension EncodingError { /// Returns a `.invalidValue` error describing the given invalid floating-point value. @@ -1133,7 +1133,7 @@ private extension YAMLValue { return true } } - + var isContainer: Bool { switch self { case .array, .object: @@ -1164,33 +1164,32 @@ private extension YAMLValue { } 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/DeploymentDecsriptorBase.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift deleted file mode 100644 index e9dd961e..00000000 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDecsriptorBase.swift +++ /dev/null @@ -1,126 +0,0 @@ -// ===----------------------------------------------------------------------===// -// -// 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 { - - func generateAndTestDeploymentDescriptor(deployment: T, - expected: [String]) -> Bool { - // when - let samJSON = deployment.toJSON() - - // then - let result = expected.allSatisfy { samJSON.contains( $0 ) } - - if (!result) { - print("===========") - print(samJSON) - print("-----------") - print(expected.filter{ !samJSON.contains( $0 ) }.compactMap{ $0 }) - print("===========") - } - - return result - } - - func generateAndTestDeploymentDescriptor(deployment: T, - expected: String) -> Bool { - return generateAndTestDeploymentDescriptor(deployment: deployment, expected: [expected]) - } - - func expectedSAMHeaders() -> [String] { - return [""" -"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", codeURI: String = "ERROR") -> [String] { - return [""" -"Resources":{"TestLambda":{ -""", -""" -"Type":"AWS::Serverless::Function" -""", -""" -"AutoPublishAlias":"Live" -""", -""" -"Handler":"Provided" -""", -""" -"CodeUri":"\(codeURI)" -""", -""" -"Runtime":"provided.al2" -""", -""" -"Architectures":["\(architecture)"] -"""] - } - - func expectedEnvironmentVariables() -> [String] { - return [""" -"Environment":{"Variables":{"NAME1":"VALUE1"}} -"""] - } - - func expectedHttpAPi() -> [String] { - return [""" -"HttpApiEvent":{"Type":"HttpApi"} -"""] - } - - func expectedQueue() -> [String] { - return [""" -"Resources": -""", -""" -"QueueTestQueue": -""", -""" -"Type":"AWS::SQS::Queue" -""", -""" -"Properties":{"QueueName":"test-queue"} -"""] - } - - func expectedQueueEventSource(source: String) -> [String] { - return [ -""" -"SQSEvent" -""", -""" -"Type":"SQS" -""", -""" -\(source) -""", -""" -"BatchSize":10 -""", -""" -"Enabled":true -""" - ] - } -} \ No newline at end of file diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift new file mode 100644 index 00000000..3daf3b46 --- /dev/null +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift @@ -0,0 +1,188 @@ +// ===----------------------------------------------------------------------===// +// +// 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 + +private enum TestError: Error { + case canNotCreateDummyPackage(uri: String) +} +class DeploymentDescriptorBaseTest: XCTestCase { + + var codeURI: String! = nil + let fileManager = FileManager.default + + override func setUpWithError() throws { + // create a fake lambda package zip file + codeURI = "\(fileManager.temporaryDirectory.path)/fakeLambda.zip" + if !fileManager.createFile(atPath: codeURI!, + contents: codeURI.data(using: .utf8), + attributes: [.posixPermissions: 0o700]) { + throw TestError.canNotCreateDummyPackage(uri: codeURI) + } + } + + override func tearDownWithError() throws { + // delete the fake lambda package (silently ignore errors) + try? fileManager.removeItem(atPath: codeURI!) + codeURI = nil + } + + // 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: [ + "AutoPublishAlias": "Live", + "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 index 80ef6f65..67ccdf7b 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -20,110 +20,105 @@ import XCTest // and the check on existence of the ZIP file // the rest is boiler plate code final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { - + func testGenericFunction() { - + // given - let expected = [expectedSAMHeaders(), - expectedFunction(), - expectedEnvironmentVariables(), - expectedHttpAPi()].flatMap { $0 } - + 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)) - + } - + // 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() { - - // given - let expected = """ -"CodeUri":"ERROR" -""" - - let testDeployment = MockDeploymentDescriptorBuilder( - withFunction: true, - architecture: .arm64, - eventSource: HttpApi().resource(), - environmentVariable: ["NAME1": "VALUE1"] ) - - XCTAssertTrue(generateAndTestDeploymentDescriptor(deployment: testDeployment, - expected: expected)) + + // when + let testFunction = Function(name: "TestFunction", + codeURI: "/path/does/not/exist/lambda.zip") { + EventSources { } + } + + // then + XCTAssertThrowsError(try testFunction.packagePath()) } - + // check wether the builder detects existing packages func testLambdaExistingZIPPackage() { - + // given let (tempDir, tempFile) = prepareTemporaryPackageFile() - - let expected = """ -"CodeUri":"\(tempFile)" -""".replacingOccurrences(of: "/", with: "\\/") - + + 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 deleteTemporaryPackageFile(tempFile) } - - private func prepareTemporaryPackageFile() -> (String,String) { + + private func prepareTemporaryPackageFile() -> (String, String) { let fm = FileManager.default let tempDir = fm.temporaryDirectory let packageDir = MockDeploymentDescriptorBuilder.packageDir() @@ -134,7 +129,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { XCTAssertTrue(fm.createFile(atPath: tempFile, contents: nil)) return (tempDir.path, tempFile) } - + private func deleteTemporaryPackageFile(_ file: String) { let fm = FileManager.default XCTAssertNoThrow(try fm.removeItem(atPath: file)) diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index e0ee6b73..8cae5693 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -18,260 +18,299 @@ import XCTest // this test case tests the generation of the SAM deployment descriptor in JSON final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { - private var defaultCodeUri = "\\/path\\/lambda.zip" func testSAMHeader() { - + // given let expected = expectedSAMHeaders() - - let testDeployment = MockDeploymentDescriptor(withFunction: false) + + let testDeployment = MockDeploymentDescriptor(withFunction: false, codeURI: self.codeURI) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testLambdaFunctionResource() { - + // given - let expected = [expectedFunction(codeURI: defaultCodeUri), expectedSAMHeaders()].flatMap{ $0 } + let expected = [expectedFunction(), expectedSAMHeaders()].flatMap { $0 } - let testDeployment = MockDeploymentDescriptor(withFunction: true) + let testDeployment = MockDeploymentDescriptor(withFunction: true, codeURI: self.codeURI) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testLambdaFunctionWithSpecificArchitectures() { - + // given - let expected = [expectedFunction(architecture: Architectures.x64.rawValue, codeURI: defaultCodeUri), + let expected = [expectedFunction(architecture: Architectures.x64.rawValue), expectedSAMHeaders()] - .flatMap{ $0 } + .flatMap { $0 } let testDeployment = MockDeploymentDescriptor(withFunction: true, - architecture: .x64) + architecture: .x64, + codeURI: self.codeURI) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testSimpleTableResource() { - + // given - let expected = [""" -"Resources":{"LogicalTestTable" -""", -""" -"Type":"AWS::Serverless::SimpleTable" -""", -""" -"TableName":"TestTable" -""", -""" -"PrimaryKey" -""", -""" -"Name":"pk" -""", -""" -"Type":"String" -"""] - - + 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, - additionalResources: - [.table(logicalName: "LogicalTestTable", - physicalName: "TestTable", - primaryKeyName: "pk", - primaryKeyType: "String")] + 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, - additionalResources: - [.queue(name: "QueueTestQueue", - properties: SQSResourceProperties(queueName: "test-queue"))] - + codeURI: self.codeURI, + additionalResources: [ queue ] + ) + + // test XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testHttpApiEventSourceCatchAll() { - + // given - let expected = [expectedSAMHeaders(), - expectedFunction(architecture: Architectures.defaultArchitecture().rawValue, codeURI: defaultCodeUri), -[""" -"HttpApiEvent":{"Type":"HttpApi"} -"""] ].flatMap{ $0 } - + let expected = expectedSAMHeaders() + + expectedFunction(architecture: 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, - eventSource: [ .httpApi() ] ) - + codeURI: self.codeURI, + eventSource: [ httpApi ] ) + + // then XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testHttpApiEventSourceSpecific() { - + // given - let expected = [expectedSAMHeaders(), - expectedFunction(architecture: Architectures.defaultArchitecture().rawValue, codeURI: defaultCodeUri), -[""" -{"HttpApiEvent": -""", -""" -"Type":"HttpApi" -""", -""" -"Properties" -""", -""" -"Path":"\\/test" -""", -""" -"Method":"GET" -"""] ].flatMap{ $0 } - + let expected = expectedSAMHeaders() + + expectedFunction(architecture: 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, - eventSource: [ .httpApi(method: .GET, path: "/test") ]) - + 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(codeURI: defaultCodeUri), - expectedQueueEventSource(source: name) - ].flatMap{ $0 } - + 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, - eventSource: [ .sqs(queue: name) ] ) - + codeURI: self.codeURI, + eventSource: [ queue ] ) + + // then XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testSQSEventSourceWithoutArn() { - - let name = """ - "Queue":{"Fn::GetAtt":["QueueQueueLambdaTest","Arn"]} - """ // 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, - eventSource: [ .sqs(queue: "queue-lambda-test") ] ) - - let expected = [ expectedSAMHeaders(), - expectedFunction(codeURI: defaultCodeUri), - expectedQueueEventSource(source: name) - ].flatMap{ $0 } + codeURI: self.codeURI, + eventSource: [ queue ] ) + // then XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - + func testEnvironmentVariablesString() { - + // given - let expected = [""" -"Environment" -""", -""" -"Variables" -""", -""" -"TEST2_VAR":"TEST2_VALUE" -""", -""" -"TEST1_VAR":"TEST1_VALUE" -"""] - - + 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 = """ -"Environment":{"Variables":{"TEST1_VAR":{"Ref":"TEST1_VALUE"}}} -""" - + 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"]) + 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 = """ -"Environment":{"Variables":{"TEST1_VAR":{"Fn::GetAtt":["TEST1_VALUE","Arn"]}}} -""" - + 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"]]) + 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 = """ -"Environment":{"Variables":{"TEST1_VAR":{"Ref":"LogicalName"}}} -""" - - let resource = Resource.queue(logicalName: "LogicalName", physicalName: "PhysicalName") + 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 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) } - + } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index d7b33d2f..9b3e7d81 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -17,31 +17,35 @@ import Foundation protocol MockDeploymentDescriptorBehavior { func toJSON() -> String + func toYAML() -> String } struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { - - let deploymentDescriptor : SAMDeploymentDescriptor - + + let deploymentDescriptor: SAMDeploymentDescriptor + init(withFunction: Bool = true, architecture: Architectures = Architectures.defaultArchitecture(), - eventSource: [Resource]? = nil, + codeURI: String, + eventSource: [Resource]? = nil, environmentVariable: SAMEnvironmentVariable? = nil, - additionalResources: [Resource]? = nil) - { + additionalResources: [Resource]? = nil) { if withFunction { + + let properties = ServerlessFunctionProperties( + codeUri: codeURI, + architecture: architecture, + eventSources: eventSource ?? [], + environment: environmentVariable ?? SAMEnvironmentVariable.none) + let serverlessFunction = Resource( + type: .serverlessFunction, + properties: properties, + name: "TestLambda") + self.deploymentDescriptor = SAMDeploymentDescriptor( description: "A SAM template to deploy a Swift Lambda function", - resources: [ - .serverlessFunction( - name: "TestLambda", - architecture: architecture, - codeUri: "/path/lambda.zip", - eventSources: eventSource ?? [], - environment: environmentVariable ?? SAMEnvironmentVariable.none - ) - ] + (additionalResources ?? []) - + resources: [ serverlessFunction ] + (additionalResources ?? []) + ) } else { self.deploymentDescriptor = SAMDeploymentDescriptor( @@ -53,25 +57,29 @@ struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { func toJSON() -> String { return self.deploymentDescriptor.toJSON(pretty: false) } + func toYAML() -> String { + return self.deploymentDescriptor.toYAML() + } } struct MockDeploymentDescriptorBuilder: MockDeploymentDescriptorBehavior { - - static let functioName = "TestLambda" - let deploymentDescriptor : DeploymentDescriptor - + + static let functionName = "TestLambda" + let deploymentDescriptor: DeploymentDescriptor + init(withFunction: Bool = true, architecture: Architectures = Architectures.defaultArchitecture(), - eventSource: Resource, - environmentVariable: [String:String]) - { + codeURI: String, + eventSource: Resource, + environmentVariable: [String: String]) { if withFunction { - + self.deploymentDescriptor = DeploymentDescriptor { "A SAM template to deploy a Swift Lambda function" - - Function(name: MockDeploymentDescriptorBuilder.functioName, - architecture: architecture) { + + Function(name: MockDeploymentDescriptorBuilder.functionName, + architecture: architecture, + codeURI: codeURI) { EventSources { eventSource } @@ -80,23 +88,25 @@ struct MockDeploymentDescriptorBuilder: MockDeploymentDescriptorBehavior { } } } - + } 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 "/\(functioName)" + return "/\(functionName)" } static func packageZip() -> String { - return "/\(functioName).zip" + return "/\(functionName).zip" } } - diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift index b7ee808e..d2b9aa52 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift @@ -30,7 +30,7 @@ struct TopLevelObjectWrapper: Codable, Equatable { } } -class TestYAMLEncoder : XCTestCase { +class TestYAMLEncoder: XCTestCase { // MARK: - Encoding Top-Level fragments func test_encodingTopLevelFragments() { @@ -351,7 +351,6 @@ dictionary: 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) @@ -381,7 +380,7 @@ dictionary: func test_nestedContainerCodingPaths() { let encoder = YAMLEncoder() do { - let _ = try encoder.encode(NestedContainersTestType()) + _ = try encoder.encode(NestedContainersTestType()) } catch { XCTFail("Caught error during encoding nested container types: \(error)") } @@ -390,7 +389,7 @@ dictionary: func test_superEncoderCodingPaths() { let encoder = YAMLEncoder() do { - let _ = try encoder.encode(NestedContainersTestType(testSuperEncoder: true)) + _ = try encoder.encode(NestedContainersTestType(testSuperEncoder: true)) } catch { XCTFail("Caught error during encoding nested container types: \(error)") } @@ -425,7 +424,6 @@ dictionary: // 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? @@ -587,7 +585,6 @@ dictionary: test_codingOf(value: URL(string: "https://swift.org")!, toAndFrom: "https://swift.org") } - // UInt and Int func test_codingOfUIntMinMax() { @@ -647,18 +644,18 @@ dictionary: return [""] } - private func _testEncodeFailure(of value: T) { + private func _testEncodeFailure(of value: T) { do { - let _ = try YAMLEncoder().encode(value) + _ = 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, + dateEncodingStrategy: YAMLEncoder.DateEncodingStrategy = .deferredToDate, dataEncodingStrategy: YAMLEncoder.DataEncodingStrategy = .base64, - nonConformingFloatEncodingStrategy: YAMLEncoder.NonConformingFloatEncodingStrategy = .throw) where T : Codable, T : Equatable { + nonConformingFloatEncodingStrategy: YAMLEncoder.NonConformingFloatEncodingStrategy = .throw) where T: Codable, T: Equatable { var payload: Data! = nil do { let encoder = YAMLEncoder() @@ -669,7 +666,7 @@ dictionary: } 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 @@ -680,12 +677,12 @@ dictionary: 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) { + + if !result { print("===========") print(payloadYAMLObject) print("-----------") - print(yaml.filter{ !payloadYAMLObject.contains( $0 ) }.compactMap{ $0 }) + print(yaml.filter { !payloadYAMLObject.contains( $0 ) }.compactMap { $0 }) print("===========") } } @@ -746,13 +743,13 @@ func expectEqualPaths(_ lhs: [CodingKey?], _ rhs: [CodingKey?], _ prefix: String /* FIXME: Import from %S/Inputs/Coding/SharedTypes.swift somehow. */ // MARK: - Empty Types -fileprivate struct EmptyStruct : Codable, Equatable { +private struct EmptyStruct: Codable, Equatable { static func ==(_ lhs: EmptyStruct, _ rhs: EmptyStruct) -> Bool { return true } } -fileprivate class EmptyClass : Codable, Equatable { +private class EmptyClass: Codable, Equatable { static func ==(_ lhs: EmptyClass, _ rhs: EmptyClass) -> Bool { return true } @@ -760,7 +757,7 @@ fileprivate class EmptyClass : Codable, Equatable { // MARK: - Single-Value Types /// A simple on-off switch type that encodes as a single Bool value. -fileprivate enum Switch : Codable { +private enum Switch: Codable { case off case on @@ -782,7 +779,7 @@ fileprivate enum Switch : Codable { } /// A simple timestamp type that encodes as a single Double value. -fileprivate struct Timestamp : Codable, Equatable { +private struct Timestamp: Codable, Equatable { let value: Double init(_ value: Double) { @@ -805,7 +802,7 @@ fileprivate struct Timestamp : Codable, Equatable { } /// A simple referential counter type that encodes as a single Int value. -fileprivate final class Counter : Codable, Equatable { +private final class Counter: Codable, Equatable { var count: Int = 0 init() {} @@ -827,7 +824,7 @@ fileprivate final class Counter : Codable, Equatable { // MARK: - Structured Types /// A simple address type that encodes as a dictionary of values. -fileprivate struct Address : Codable, Equatable { +private struct Address: Codable, Equatable { let street: String let city: String let state: String @@ -860,7 +857,7 @@ fileprivate struct Address : Codable, Equatable { } /// A simple person class that encodes as a dictionary of values. -fileprivate class Person : Codable, Equatable { +private class Person: Codable, Equatable { let name: String let email: String @@ -887,7 +884,7 @@ fileprivate class Person : Codable, Equatable { } /// A simple company struct which encodes as a dictionary of nested values. -fileprivate struct Company : Codable, Equatable { +private struct Company: Codable, Equatable { let address: Address var employees: [Person] @@ -909,7 +906,7 @@ fileprivate struct Company : Codable, Equatable { /// A key type which can take on any string or integer value. /// This needs to mirror _YAMLKey. -fileprivate struct _TestKey : CodingKey { +private struct _TestKey: CodingKey { var stringValue: String var intValue: Int? @@ -930,7 +927,7 @@ fileprivate struct _TestKey : CodingKey { } /// Wraps a type T so that it can be encoded at the top level of a payload. -fileprivate struct TopLevelArrayWrapper : Codable, Equatable where T : Codable, T : Equatable { +private struct TopLevelArrayWrapper: Codable, Equatable where T: Codable, T: Equatable { let value: T init(_ value: T) { @@ -953,7 +950,7 @@ fileprivate struct TopLevelArrayWrapper : Codable, Equatable where T : Codabl } } -fileprivate struct FloatNaNPlaceholder : Codable, Equatable { +private struct FloatNaNPlaceholder: Codable, Equatable { init() {} func encode(to encoder: Encoder) throws { @@ -974,7 +971,7 @@ fileprivate struct FloatNaNPlaceholder : Codable, Equatable { } } -fileprivate struct DoubleNaNPlaceholder : Codable, Equatable { +private struct DoubleNaNPlaceholder: Codable, Equatable { init() {} func encode(to encoder: Encoder) throws { @@ -996,7 +993,7 @@ fileprivate struct DoubleNaNPlaceholder : Codable, Equatable { } /// A type which encodes as an array directly through a single value container. -struct Numbers : Codable, Equatable { +struct Numbers: Codable, Equatable { let values = [4, 8, 15, 16, 23, 42] init() {} @@ -1024,16 +1021,16 @@ struct Numbers : Codable, Equatable { } /// A type which encodes as a dictionary directly through a single value container. -fileprivate final class Mapping : Codable, Equatable { - let values: [String : URL] +private final class Mapping: Codable, Equatable { + let values: [String: URL] - init(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) + values = try container.decode([String: URL].self) } func encode(to encoder: Encoder) throws { @@ -1051,20 +1048,20 @@ fileprivate final class Mapping : Codable, Equatable { } } -struct NestedContainersTestType : Encodable { +struct NestedContainersTestType: Encodable { let testSuperEncoder: Bool init(testSuperEncoder: Bool = false) { self.testSuperEncoder = testSuperEncoder } - enum TopLevelCodingKeys : Int, CodingKey { + enum TopLevelCodingKeys: Int, CodingKey { case a case b case c } - enum IntermediateCodingKeys : Int, CodingKey { + enum IntermediateCodingKeys: Int, CodingKey { case one case two } @@ -1130,7 +1127,7 @@ struct NestedContainersTestType : Encodable { 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.") @@ -1143,7 +1140,7 @@ struct NestedContainersTestType : Encodable { // MARK: - Helpers -fileprivate struct YAML: Equatable { +private struct YAML: Equatable { private var jsonObject: Any fileprivate init(data: Data) throws { @@ -1221,7 +1218,7 @@ extension TestYAMLEncoder { // ("test_dictionary_snake_case_decoding", test_dictionary_snake_case_decoding), // ("test_dictionary_snake_case_encoding", test_dictionary_snake_case_encoding), ("test_OutputFormattingValues", test_OutputFormattingValues), - ("test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip", test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip), + ("test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip", test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip) ] } } From 04be9b8496e8db3df10262dd022a6114078d7646 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 3 Apr 2023 08:29:18 +0200 Subject: [PATCH 59/79] modify archiver plugin to support local development --- Plugins/AWSLambdaPackager/Plugin.swift | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index ed17d1e3..f4779277 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -110,11 +110,24 @@ struct AWSLambdaPackager: CommandPlugin { for product in products { print("building \"\(product.name)\"") let buildCommand = "swift build -c \(buildConfiguration.rawValue) --product \(product.name) --static-swift-stdlib" - try Utils.execute( - executable: dockerToolPath, - arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], - logLevel: verboseLogging ? .debug : .output - ) + if ProcessInfo.processInfo.environment["LAMBDA_USE_LOCAL_DEPS"] != nil { + // when developing locally, we must have the full swift-aws-lambda-runtime project in the container + // because Examples' Package.swift have a dependency on ../.. + // 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 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 Utils.execute( + executable: dockerToolPath, + arguments: ["run", "--rm", "-v", "\(packageDirectory.string):/workspace", "-w", "/workspace", baseImage, "bash", "-cl", buildCommand], + logLevel: verboseLogging ? .debug : .output + ) + } let productPath = buildOutputPath.appending(product.name) guard FileManager.default.fileExists(atPath: productPath.string) else { Diagnostics.error("expected '\(product.name)' binary at \"\(productPath.string)\"") From 86047035fdea77afde0960d9a37a2258fe8a23ed Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 3 Apr 2023 11:23:16 +0200 Subject: [PATCH 60/79] align default build configuration to archiver plugin's default --- Plugins/AWSLambdaDeployer/Plugin.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index fb692e34..eeeded3c 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -479,7 +479,7 @@ private struct Configuration: CustomStringConvertible { } self.buildConfiguration = buildConfiguration } else { - self.buildConfiguration = .debug + self.buildConfiguration = .release } // use a default archive directory when none are given From 97650492aab8b0b84b5b7e0ff6dd566d07648a2e Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 3 Apr 2023 18:53:29 +0200 Subject: [PATCH 61/79] make sure the lib DeploymentDescriptor is built before the plugin runs other commands --- Examples/SAM/Package.swift | 3 --- Plugins/AWSLambdaDeployer/Plugin.swift | 29 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index 842ac3dc..3ba60d26 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -52,9 +52,6 @@ let package = Package( dependencies: [ "HttpApiLambda", "SQSLambda", .product(name: "AWSLambdaTesting", package: "swift-aws-lambda-runtime"), - // This dependency must be included to force Swift to build the deployment descriptor dynamic library - // It can be on any target. But as of Swift 5.8, it can not be added to the plugin target itself 😢 - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") ], // testing data resources: [ diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index eeeded3c..31104643 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -48,6 +48,12 @@ struct AWSLambdaDeployer: CommandPlugin { 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, @@ -90,7 +96,26 @@ struct AWSLambdaDeployer: CommandPlugin { 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, @@ -408,7 +433,7 @@ OPTIONS: --configuration Build for a specific configuration. Must be aligned with what was used to build and package. - Valid values: [ debug, release ] (default: debug) + 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. From 00dfdcad775dc1a5f54dd9d4aa6321e24e87fd6c Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 5 Apr 2023 16:33:37 +0200 Subject: [PATCH 62/79] add check if AWS CLI is configured --- Plugins/AWSLambdaDeployer/Plugin.swift | 41 +++++++++++++++----------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 31104643..8ec8f52b 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -383,26 +383,31 @@ image_repositories = [] 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) { + verboseLogging: verboseLogging) { - // aws --profile default configure get region - do { - result = try Utils.execute( - executable: awsCLIPath, - arguments: ["--profile", "default", - "configure", - "get", "region"], - logLevel: verboseLogging ? .debug : .silent) + 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) + } - 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! + 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.") } } From e1177a603df96e053b48eb2f8ca188d2d9574ff2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 6 Apr 2023 07:57:04 +0200 Subject: [PATCH 63/79] update README to match latest code change --- Plugins/AWSLambdaDeployer/README.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Plugins/AWSLambdaDeployer/README.md b/Plugins/AWSLambdaDeployer/README.md index e6e4f979..733a0870 100644 --- a/Plugins/AWSLambdaDeployer/README.md +++ b/Plugins/AWSLambdaDeployer/README.md @@ -92,28 +92,10 @@ DeploymentDescriptor { } ``` -3. I add a dependency in my project's `Package.swift`. On a `testTarget`, I add this dependency: - -```swift - // on the testTarget - dependencies: [ - // other dependencies - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime") - ] -``` - -I also might add this dependency on one of my Lambda functions `executableTarget`. In this case, I make sure it is added only when building on macOS. - -```swift - .product(name: "AWSLambdaDeploymentDescriptor", package: "swift-aws-lambda-runtime", condition: .when(platforms: [.macOS])) -``` - 3. I invoke the archive plugin and the deploy plugin from the command line. ```bash -swift build - # first create the zip file swift package --disable-sandbox archive From 047d153f163f7e64113d03a48dc8b5b48fb984b9 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 6 Apr 2023 08:10:48 +0200 Subject: [PATCH 64/79] deployment descriptor description is optional as per SAM spec --- Examples/SAM/Deploy.swift | 2 +- .../DeploymentDescriptor.swift | 2 +- .../DeploymentDescriptorBuilder.swift | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift index 4a4394ae..1c05a0a4 100644 --- a/Examples/SAM/Deploy.swift +++ b/Examples/SAM/Deploy.swift @@ -11,7 +11,7 @@ let sharedEnvironmentVariables = ["LOG_LEVEL": "debug"] // the deployment descriptor DeploymentDescriptor { - // a mandatory description + // an optional description "Description of this deployment descriptor" // a lambda function diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index d1b00008..508fd954 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -28,7 +28,7 @@ public struct SAMDeploymentDescriptor: Encodable { var resources: [String: Resource] = [:] public init( - description: String = "A SAM template to deploy a Swift Lambda function", + description: String, resources: [Resource] = [] ) { self.description = description diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index c13ba60c..823dc164 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -28,7 +28,7 @@ public struct DeploymentDescriptor { // MARK: Generation of the SAM Deployment Descriptor private init( - description: String, + description: String = "A SAM template to deploy a Swift Lambda function", resources: [Resource] ) { @@ -55,17 +55,18 @@ public struct DeploymentDescriptor { self = builder() } public static func buildBlock(_ description: String, - _ resources: [Resource]...) -> (String, [Resource]) { + _ resources: [Resource]...) -> (String?, [Resource]) { return (description, resources.flatMap { $0 }) } - @available(*, unavailable, - message: "The first statement of DeploymentDescriptor must be its description String" - ) - public static func buildBlock(_ resources: [Resource]...) -> (String, [Resource]) { - fatalError() + public static func buildBlock(_ resources: [Resource]...) -> (String?, [Resource]) { + return (nil, resources.flatMap { $0 }) } - public static func buildFinalResult(_ function: (String, [Resource])) -> DeploymentDescriptor { - return DeploymentDescriptor(description: function.0, resources: function.1) + 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 { return expression From 8dfab34bb0606a64597f9553d23480941d970f27 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 13 Apr 2023 10:32:19 +0200 Subject: [PATCH 65/79] add automatic camel case conversion for keys --- .../YAMLEncoder.swift | 11 + .../YAMLEncoderTests.swift | 2114 +++++++++-------- 2 files changed, 1157 insertions(+), 968 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift index 10e9c4bb..d44be90d 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift @@ -106,11 +106,19 @@ open class YAMLEncoder { public enum KeyEncodingStrategy { /// Use the keys specified by each type. This is the default strategy. case useDefaultKeys + + /// convert keyname to camelcase. + /// for example myMaxValue becomes MyMaxValue + case camelCase /// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types. /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. /// If the result of the conversion is a duplicate key, then only one value will be present in the result. case custom((_ codingPath: [CodingKey]) -> CodingKey) + + fileprivate static func _convertToCamelCase(_ stringKey: String) -> String { + return stringKey.prefix(1).capitalized + stringKey.dropFirst() + } } /// The output format to produce. Defaults to `withoutEscapingSlashes` for YAML. @@ -564,6 +572,9 @@ private struct YAMLKeyedEncodingContainer: KeyedEncodingContainerP switch self.options.keyEncodingStrategy { case .useDefaultKeys: return key + case .camelCase: + let newKeyString = YAMLEncoder.KeyEncodingStrategy._convertToCamelCase(key.stringValue) + return _YAMLKey(stringValue: newKeyString, intValue: key.intValue) case .custom(let converter): return converter(codingPath + [key]) } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift index d2b9aa52..33993981 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift @@ -16,727 +16,845 @@ // 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 + var value: T - static func ==(lhs: TopLevelObjectWrapper, rhs: TopLevelObjectWrapper) -> Bool { - return lhs.value == rhs.value - } + static func == (lhs: TopLevelObjectWrapper, rhs: TopLevelObjectWrapper) -> Bool { + return lhs.value == rhs.value + } - init(_ value: T) { - self.value = 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 fragments + func test_encodingTopLevelFragments() { - // MARK: - Encoding Top-Level Empty Types - func test_encodingTopLevelEmptyStruct() { - let empty = EmptyStruct() - _testRoundTrip(of: empty, expectedYAML: _yamlEmptyDictionary) - } + func _testFragment(value: T, fragment: String) { + let data: Data + let payload: String - func test_encodingTopLevelEmptyClass() { - let empty = EmptyClass() - _testRoundTrip(of: empty, expectedYAML: _yamlEmptyDictionary) - } + 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 Single-Value Types - func test_encodingTopLevelSingleValueEnum() { - _testRoundTrip(of: Switch.off) - _testRoundTrip(of: Switch.on) + // MARK: - Encoding Top-Level Empty Types + func test_encodingTopLevelEmptyStruct() { + let empty = EmptyStruct() + _testRoundTrip(of: empty, expectedYAML: _yamlEmptyDictionary) + } - _testRoundTrip(of: TopLevelArrayWrapper(Switch.off)) - _testRoundTrip(of: TopLevelArrayWrapper(Switch.on)) - } + func test_encodingTopLevelEmptyClass() { + let empty = EmptyClass() + _testRoundTrip(of: empty, expectedYAML: _yamlEmptyDictionary) + } - func test_encodingTopLevelSingleValueStruct() { - _testRoundTrip(of: Timestamp(3141592653)) - _testRoundTrip(of: TopLevelArrayWrapper(Timestamp(3141592653))) - } + // MARK: - Encoding Top-Level Single-Value Types + func test_encodingTopLevelSingleValueEnum() { + _testRoundTrip(of: Switch.off) + _testRoundTrip(of: Switch.on) - func test_encodingTopLevelSingleValueClass() { - _testRoundTrip(of: Counter()) - _testRoundTrip(of: TopLevelArrayWrapper(Counter())) - } + _testRoundTrip(of: TopLevelArrayWrapper(Switch.off)) + _testRoundTrip(of: TopLevelArrayWrapper(Switch.on)) + } - // 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_encodingTopLevelSingleValueStruct() { + _testRoundTrip(of: Timestamp(3_141_592_653)) + _testRoundTrip(of: TopLevelArrayWrapper(Timestamp(3_141_592_653))) + } - 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_encodingTopLevelSingleValueClass() { + _testRoundTrip(of: Counter()) + _testRoundTrip(of: TopLevelArrayWrapper(Counter())) + } - func test_encodingTopLevelStructuredSingleStruct() { - // Numbers is a struct which encodes as an array through a single value container. - let numbers = Numbers.testValue - _testRoundTrip(of: numbers) - } + // 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_encodingTopLevelStructuredSingleClass() { - // Mapping is a class which encodes as a dictionary through a single value container. - let mapping = Mapping.testValue - _testRoundTrip(of: mapping) - } + 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_encodingTopLevelDeepStructuredType() { - // Company is a type with fields which are Codable themselves. - let company = Company.testValue - _testRoundTrip(of: company) - } + func test_encodingTopLevelStructuredSingleStruct() { + // Numbers is a struct which encodes as an array through a single value container. + let numbers = Numbers.testValue + _testRoundTrip(of: numbers) + } - // 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_encodingTopLevelStructuredSingleClass() { + // Mapping is a class which encodes as a dictionary through a single value container. + let mapping = Mapping.testValue + _testRoundTrip(of: mapping) + } - 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_encodingTopLevelDeepStructuredType() { + // Company is a type with fields which are Codable themselves. + let company = Company.testValue + _testRoundTrip(of: company) + } - func test_encodingOutputFormattingSortedKeys() { - let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] - let person = Person.testValue - _testRoundTrip(of: person, expectedYAML: expectedYAML) - } + // 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_encodingOutputFormattingPrettyPrintedSortedKeys() { - 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: + """) + } - // 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_encodingOutputFormattingSortedKeys() { + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + } - func test_encodingDateSecondsSince1970() { - let seconds = 1000.0 - let expectedYAML = ["1000"] + func test_encodingOutputFormattingPrettyPrintedSortedKeys() { + let expectedYAML = ["name: Johnny Appleseed", "email: appleseed@apple.com"] + let person = Person.testValue + _testRoundTrip(of: person, expectedYAML: expectedYAML) + } - let d = Date(timeIntervalSince1970: seconds) - _testRoundTrip(of: d, - expectedYAML: expectedYAML, - dateEncodingStrategy: .secondsSince1970) - } + // 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_encodingDateMillisecondsSince1970() { - let seconds = 1000.0 - let expectedYAML = ["1000000"] + func test_encodingDateSecondsSince1970() { + let seconds = 1000.0 + let expectedYAML = ["1000"] - _testRoundTrip(of: Date(timeIntervalSince1970: seconds), - expectedYAML: expectedYAML, - dateEncodingStrategy: .millisecondsSince1970) - } + let d = Date(timeIntervalSince1970: seconds) + _testRoundTrip( + of: d, + expectedYAML: expectedYAML, + dateEncodingStrategy: .secondsSince1970) + } - func test_encodingDateISO8601() { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = .withInternetDateTime + func test_encodingDateMillisecondsSince1970() { + let seconds = 1000.0 + let expectedYAML = ["1000000"] - let timestamp = Date(timeIntervalSince1970: 1000) - let expectedYAML = ["\(formatter.string(from: timestamp))"] + _testRoundTrip( + of: Date(timeIntervalSince1970: seconds), + expectedYAML: expectedYAML, + dateEncodingStrategy: .millisecondsSince1970) + } - // 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_encodingDateISO8601() { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = .withInternetDateTime - } + let timestamp = Date(timeIntervalSince1970: 1000) + let expectedYAML = ["\(formatter.string(from: timestamp))"] - func test_encodingDateFormatted() { - let formatter = DateFormatter() - formatter.dateStyle = .full - formatter.timeStyle = .full + // We can't encode a top-level Date, so it'll be wrapped in an array. + _testRoundTrip( + of: TopLevelArrayWrapper(timestamp), + expectedYAML: expectedYAML, + dateEncodingStrategy: .iso8601) - 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_encodingDateFormatted() { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .full - 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)) - } + let timestamp = Date(timeIntervalSince1970: 1000) + let expectedYAML = ["\(formatter.string(from: timestamp))"] - func test_encodingDateCustomEmpty() { - let timestamp = Date() + // 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)) + } - // Encoding nothing should encode an empty keyed container ({}). - let encode = { (_: Date, _: Encoder) throws -> Void in } - // let decode = { (_: Decoder) throws -> Date in return timestamp } + func test_encodingDateCustom() { + let timestamp = Date() - // 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)) + // 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 } - // MARK: - Data Strategy Tests - func test_encodingBase64Data() { - let data = Data([0xDE, 0xAD, 0xBE, 0xEF]) + // 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)) + } - // 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_encodingDateCustomEmpty() { + let timestamp = Date() - 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)) - } + // Encoding nothing should encode an empty keyed container ({}). + let encode = { (_: Date, _: Encoder) throws -> Void in } + // let decode = { (_: Decoder) throws -> Date in return timestamp } - 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 Date, so it'll be wrapped in an array. + let expectedYAML = [""] + _testRoundTrip( + of: TopLevelArrayWrapper(timestamp), + expectedYAML: expectedYAML, + dateEncodingStrategy: .custom(encode)) + } - // 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: - Data Strategy Tests + func test_encodingBase64Data() { + let data = Data([0xDE, 0xAD, 0xBE, 0xEF]) - // 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)) + // 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) + } - _testEncodeFailure(of: TopLevelArrayWrapper(Double.infinity)) - _testEncodeFailure(of: TopLevelArrayWrapper(-Double.infinity)) - _testEncodeFailure(of: TopLevelArrayWrapper(Double.nan)) + 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() } - 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) - } + // 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)) + } - // 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_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)) + } - func test_superEncoderCodingPaths() { - let encoder = YAMLEncoder() - do { - _ = try encoder.encode(NestedContainersTestType(testSuperEncoder: true)) - } catch { - XCTFail("Caught error during encoding nested container types: \(error)") - } - } + // 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)) - 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)") - // } - } + _testEncodeFailure(of: TopLevelArrayWrapper(Double.infinity)) + _testEncodeFailure(of: TopLevelArrayWrapper(-Double.infinity)) + _testEncodeFailure(of: TopLevelArrayWrapper(Double.nan)) + } - // 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_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) + } - func test_codingOfNil() { - let x: Int? = nil - test_codingOf(value: x, toAndFrom: "null") + // 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_codingOfInt8() { - test_codingOf(value: Int8(-42), toAndFrom: "-42") + 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_codingOfUInt8() { - test_codingOf(value: UInt8(42), toAndFrom: "42") - } + 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)") + // } + } - func test_codingOfInt16() { - test_codingOf(value: Int16(-30042), toAndFrom: "-30042") - } + // 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_codingOfUInt16() { - test_codingOf(value: UInt16(30042), toAndFrom: "30042") - } + func test_codingOfNil() { + let x: Int? = nil + test_codingOf(value: x, toAndFrom: "null") + } - func test_codingOfInt32() { - test_codingOf(value: Int32(-2000000042), toAndFrom: "-2000000042") - } + func test_codingOfInt8() { + test_codingOf(value: Int8(-42), toAndFrom: "-42") + } - func test_codingOfUInt32() { - test_codingOf(value: UInt32(2000000042), toAndFrom: "2000000042") - } + func test_codingOfUInt8() { + test_codingOf(value: UInt8(42), toAndFrom: "42") + } - func test_codingOfInt64() { -#if !arch(arm) - test_codingOf(value: Int64(-9000000000000000042), toAndFrom: "-9000000000000000042") -#endif - } + func test_codingOfInt16() { + test_codingOf(value: Int16(-30042), toAndFrom: "-30042") + } - func test_codingOfUInt64() { -#if !arch(arm) - test_codingOf(value: UInt64(9000000000000000042), toAndFrom: "9000000000000000042") -#endif - } + 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_codingOfInt() { - let intSize = MemoryLayout.size - switch intSize { - case 4: // 32-bit - test_codingOf(value: Int(-2000000042), toAndFrom: "-2000000042") - case 8: // 64-bit -#if arch(arm) - break -#else - test_codingOf(value: Int(-9000000000000000042), toAndFrom: "-9000000000000000042") -#endif - default: - XCTFail("Unexpected UInt size: \(intSize)") - } + 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(2000000042), toAndFrom: "2000000042") - case 8: // 64-bit -#if arch(arm) - break -#else - test_codingOf(value: UInt(9000000000000000042), toAndFrom: "9000000000000000042") -#endif - default: - XCTFail("Unexpected UInt size: \(uintSize)") - } + 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") + 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)!)) - } + // 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") + 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)!)) - } + // 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") + 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)!)) - } + // 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_codingOfString() { + test_codingOf(value: "Hello, world!", toAndFrom: "Hello, world!") + } - func test_codingOfURL() { - test_codingOf(value: URL(string: "https://swift.org")!, toAndFrom: "https://swift.org") - } + 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"]) - } + // 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_OutputFormattingValues() { - XCTAssertEqual(YAMLEncoder.OutputFormatting.withoutEscapingSlashes.rawValue, 8) - } + 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_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) - } + func test_DictionaryCamelCaseEncoding() throws { + let camelCaseDictionary = ["camelCaseKey": ["nestedDictionary": 1]] - // MARK: - Helper Functions - private var _yamlEmptyDictionary: [String] { - return [""] - } + let encoder = YAMLEncoder() + encoder.keyEncodingStrategy = .camelCase + let encodedData = try encoder.encode(camelCaseDictionary) - private func _testEncodeFailure(of value: T) { - do { - _ = try YAMLEncoder().encode(value) - XCTFail("Encode of top-level \(T.self) was expected to fail.") - } catch {} + 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 + } - 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("===========") - } + 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 + } } - func test_codingOf(value: T, toAndFrom stringValue: String) { - _testRoundTrip(of: TopLevelObjectWrapper(value), - expectedYAML: ["value: \(stringValue)"]) + // 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 {} + } - _testRoundTrip(of: TopLevelArrayWrapper(value), - expectedYAML: ["\(stringValue)"]) + 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)") + 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 + } } - 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)')") - } + XCTAssertEqual( + key1.stringValue, + key2.stringValue, + "\(prefix) CodingKey.stringValue mismatch: \(type(of: key1))('\(key1.stringValue)') != \(type(of: key2))('\(key2.stringValue)')" + ) + } } // MARK: - Test Types @@ -744,162 +862,158 @@ func expectEqualPaths(_ lhs: [CodingKey?], _ rhs: [CodingKey?], _ prefix: String // MARK: - Empty Types private struct EmptyStruct: Codable, Equatable { - static func ==(_ lhs: EmptyStruct, _ rhs: EmptyStruct) -> Bool { - return true - } + static func == (_ lhs: EmptyStruct, _ rhs: EmptyStruct) -> Bool { + return true + } } private class EmptyClass: Codable, Equatable { - static func ==(_ lhs: EmptyClass, _ rhs: EmptyClass) -> Bool { - return true - } + 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 - } + 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) - } + 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 + let value: Double - init(_ value: Double) { - self.value = value - } + init(_ value: Double) { + self.value = value + } - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - value = try container.decode(Double.self) - } + 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) - } + 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 - } + 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 + var count: Int = 0 - init() {} + init() {} - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - count = try container.decode(Int.self) - } + 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) - } + 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 - } + 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 - } + 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 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") - } + 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 - } + 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 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") - } + 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] + let address: Address + var employees: [Person] - init(address: Address, employees: [Person]) { - self.address = address - self.employees = employees - } + 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 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]) - } + static var testValue: Company { + return Company(address: Address.testValue, employees: [Person.testValue]) + } } // MARK: - Helper Types @@ -928,297 +1042,361 @@ private struct _TestKey: CodingKey { /// 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 + let value: T - init(_ value: T) { - self.value = value - } + init(_ value: T) { + self.value = value + } - func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(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) - } + 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 - } + static func == (_ lhs: TopLevelArrayWrapper, _ rhs: TopLevelArrayWrapper) -> Bool { + return lhs.value == rhs.value + } } private struct FloatNaNPlaceholder: Codable, Equatable { - init() {} + init() {} - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(Float.nan) - } + 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.")) - } + 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 - } + static func == (_ lhs: FloatNaNPlaceholder, _ rhs: FloatNaNPlaceholder) -> Bool { + return true + } } private struct DoubleNaNPlaceholder: Codable, Equatable { - init() {} + init() {} - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(Double.nan) - } + 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.")) - } + 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 - } + 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] + let values = [4, 8, 15, 16, 23, 42] - init() {} + 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!")) - } + 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) - } + 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 func == (_ lhs: Numbers, _ rhs: Numbers) -> Bool { + return lhs.values == rhs.values + } - static var testValue: Numbers { - return Numbers() - } + 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] + let values: [String: URL] - init(values: [String: URL]) { - self.values = values - } + init(values: [String: URL]) { + self.values = values + } - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - values = try container.decode([String: URL].self) - } + 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) - } + 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 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")!]) - } + 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 + let testSuperEncoder: Bool - init(testSuperEncoder: Bool = false) { - self.testSuperEncoder = testSuperEncoder - } + init(testSuperEncoder: Bool = false) { + self.testSuperEncoder = testSuperEncoder + } - enum TopLevelCodingKeys: Int, CodingKey { - case a - case b - case c - } + enum TopLevelCodingKeys: Int, CodingKey { + case a + case b + case c + } - enum IntermediateCodingKeys: Int, CodingKey { - case one - case two - } + 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 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.") - } + 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 + private var jsonObject: Any - fileprivate init(data: Data) throws { - self.jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - } + 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 - } + 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_numericLimits", test_numericLimits), - // ("test_snake_case_encoding", test_snake_case_encoding), - // ("test_dictionary_snake_case_decoding", test_dictionary_snake_case_decoding), - // ("test_dictionary_snake_case_encoding", test_dictionary_snake_case_encoding), - ("test_OutputFormattingValues", test_OutputFormattingValues), - ("test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip", test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip) - ] - } + 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 + ), + ] + } } From d09b53a55c3d7ceba6c14d58a64b954d021ec45c Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 13 Apr 2023 10:32:51 +0200 Subject: [PATCH 66/79] add utility to compute a SH!256 digest on files --- .../FileDigest.swift | 72 +++++++++++++++++++ .../FileDigestTests.swift | 40 +++++++++++ 2 files changed, 112 insertions(+) create mode 100644 Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift create mode 100644 Tests/AWSLambdaDeploymentDescriptorTests/FileDigestTests.swift diff --git a/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift b/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift new file mode 100644 index 00000000..c2712e54 --- /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 Foundation +import CryptoKit + +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/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") + } + } + +} From f5583751121b3342a99dabd05da34a85012c5367 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 13 Apr 2023 10:34:30 +0200 Subject: [PATCH 67/79] apply swift format --- .../FileDigest.swift | 98 +- .../YAMLEncoder.swift | 2133 +++++++++-------- 2 files changed, 1142 insertions(+), 1089 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift b/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift index c2712e54..6a63b25f 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/FileDigest.swift @@ -12,61 +12,61 @@ // // ===----------------------------------------------------------------------===// -import Foundation 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 + + public static func hex(from filePath: String?) -> String? { + guard let fp = filePath else { + return nil } - - 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) + + 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) } - - 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 + return try update(inputStream: inputStream) + } + + private func update(inputStream: InputStream) throws -> FileDigest { + inputStream.open() + defer { + inputStream.close() } - - private func update(bytes: UnsafeMutablePointer, length: Int) { - let data = Data(bytes: bytes, count: length) - digest.update(data: data) + + 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) } - - func finalize() -> String { - let digest = digest.finalize() - return digest.compactMap { String(format: "%02x", $0) }.joined() + 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 index d44be90d..b7e7b8a6 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift @@ -20,9 +20,9 @@ import Foundation /// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary` /// containing `Encodable` values (in which case it should be exempt from key conversion strategies). /// -private protocol _YAMLStringDictionaryEncodableMarker { } +private protocol _YAMLStringDictionaryEncodableMarker {} -extension Dictionary: _YAMLStringDictionaryEncodableMarker where Key == String, Value: Encodable { } +extension Dictionary: _YAMLStringDictionaryEncodableMarker where Key == String, Value: Encodable {} // ===----------------------------------------------------------------------===// // YAML Encoder @@ -30,1030 +30,1073 @@ extension Dictionary: _YAMLStringDictionaryEncodableMarker where Key == String, /// `YAMLEncoder` facilitates the encoding of `Encodable` values into YAML. open class YAMLEncoder { - // MARK: Options + // MARK: Options - /// The formatting of the output YAML data. - public struct OutputFormatting: OptionSet { - /// The format's default value. - public let rawValue: UInt + /// 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) + /// Creates an OutputFormatting value with the given raw value. + public init(rawValue: UInt) { + self.rawValue = rawValue } - /// 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 camelcase. - /// for example myMaxValue becomes MyMaxValue - case camelCase - - /// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types. - /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. - /// If the result of the conversion is a duplicate key, then only one value will be present in the result. - case custom((_ codingPath: [CodingKey]) -> CodingKey) - - fileprivate static func _convertToCamelCase(_ stringKey: String) -> String { - return stringKey.prefix(1).capitalized + stringKey.dropFirst() - } - } + /// Produce human-readable YAML with indented output. + // public static let prettyPrinted = OutputFormatting(rawValue: 1 << 0) - /// The output format to produce. Defaults to `withoutEscapingSlashes` for YAML. - open var outputFormatting: OutputFormatting = [ OutputFormatting.withoutEscapingSlashes] + /// Produce JSON with dictionary keys sorted in lexicographic order. + public static let sortedKeys = OutputFormatting(rawValue: 1 << 1) - /// The strategy to use in encoding dates. Defaults to `.deferredToDate`. - open var dateEncodingStrategy: DateEncodingStrategy = .deferredToDate + /// 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 in encoding binary data. Defaults to `.base64`. - open var dataEncodingStrategy: DataEncodingStrategy = .base64 + /// 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 - /// The strategy to use in encoding non-conforming numbers. Defaults to `.throw`. - open var nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy = .throw + /// Encode the `Date` as a UNIX timestamp (as a YAML number). + case secondsSince1970 - /// The strategy to use for encoding keys. Defaults to `.useDefaultKeys`. - open var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys + /// Encode the `Date` as UNIX millisecond timestamp (as a YAML number). + case millisecondsSince1970 - /// Contextual user-provided information for use during encoding. - open var userInfo: [CodingUserInfoKey: Any] = [:] + /// 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 - /// the number of space characters for a single indent - public static let singleIndent: Int = 3 + /// Encode the `Date` as a string formatted by the given formatter. + case formatted(DateFormatter) - /// 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 + /// 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) + } - /// Initializes `self` with default strategies. - public init() {} + /// The strategy to use for encoding `Data` values. + public enum DataEncodingStrategy { + /// Defer to `Data` for choosing an encoding. + case deferredToData - // MARK: - Encoding Values + /// Encoded the `Data` as a Base64-encoded string. This is the default strategy. + case base64 - /// Encodes the given top-level value and returns its YAML representation. + /// Encode the `Data` as a custom value encoded by the given closure. /// - /// - 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 - } + /// 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 camelcase. + /// for example myMaxValue becomes MyMaxValue + case camelCase + + /// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types. + /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. + /// If the result of the conversion is a duplicate key, then only one value will be present in the result. + case custom((_ codingPath: [CodingKey]) -> CodingKey) + + 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)) - } + case value(YAMLValue) + case encoder(YAMLEncoderImpl) + case nestedArray(RefArray) + case nestedObject(RefObject) - @inline(__always) func appendArray() -> RefArray { - let array = RefArray() - self.array.append(.nestedArray(array)) - return array - } + class RefArray { + private(set) var array: [YAMLFuture] = [] - @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([:]) - } - } - } + init() { + self.array.reserveCapacity(10) } - 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) - } - } + @inline(__always) func append(_ element: YAMLValue) { + self.array.append(.value(element)) + } - 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([:]) - } - } - } + @inline(__always) func append(_ encoder: YAMLEncoderImpl) { + self.array.append(.encoder(encoder)) } -} -private class YAMLEncoderImpl { - let options: YAMLEncoder._Options - let codingPath: [CodingKey] - var userInfo: [CodingUserInfoKey: Any] { - options.userInfo + @inline(__always) func appendArray() -> RefArray { + let array = RefArray() + self.array.append(.nestedArray(array)) + return array } - var singleValue: YAMLValue? - var array: YAMLFuture.RefArray? - var object: YAMLFuture.RefObject? + @inline(__always) func appendObject() -> RefObject { + let object = RefObject() + self.array.append(.nestedObject(object)) + return object + } - var value: YAMLValue? { - if let object = self.object { - return .object(object.values) + 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([:]) } - if let array = self.array { - return .array(array.values) + } + } + } + + 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([:]) } - return self.singleValue + } } + } +} - init(options: YAMLEncoder._Options, codingPath: [CodingKey]) { - self.options = options - self.codingPath = codingPath - } +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() - } + func container(keyedBy _: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { + if let _ = object { + let container = YAMLKeyedEncodingContainer(impl: self, codingPath: codingPath) + return KeyedEncodingContainer(container) + } - self.object = YAMLFuture.RefObject() - let container = YAMLKeyedEncodingContainer(impl: self, codingPath: codingPath) - return KeyedEncodingContainer(container) + guard self.singleValue == nil, self.array == nil else { + preconditionFailure() } - func unkeyedContainer() -> UnkeyedEncodingContainer { - if let _ = array { - return YAMLUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) - } + self.object = YAMLFuture.RefObject() + let container = YAMLKeyedEncodingContainer(impl: self, codingPath: codingPath) + return KeyedEncodingContainer(container) + } - guard self.singleValue == nil, self.object == nil else { - preconditionFailure() - } + func unkeyedContainer() -> UnkeyedEncodingContainer { + if let _ = array { + return YAMLUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + } - self.array = YAMLFuture.RefArray() - return YAMLUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + guard self.singleValue == nil, self.object == nil else { + preconditionFailure() } - func singleValueContainer() -> SingleValueEncodingContainer { - guard self.object == nil, self.array == nil else { - preconditionFailure() - } + self.array = YAMLFuture.RefArray() + return YAMLUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + } - return YAMLSingleValueEncodingContainer(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([:]) - } - } + 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 } + 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) + @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: - 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, intValue: nil)) - let encoder = YAMLEncoderImpl(options: self.options, codingPath: elemCodingPath) - - result[key] = 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) + // must be nan in this case + return .string(nanString) } - - return self.impl - } + } + + 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, intValue: nil)) + let encoder = YAMLEncoderImpl(options: self.options, codingPath: elemCodingPath) + + result[key] = 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) - case .custom(let converter): - return converter(codingPath + [key]) - } - } - - 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 - } +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) + case .custom(let converter): + return converter(codingPath + [key]) + } + } + + 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) - } + @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 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 - } + 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) - } + @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.") - } +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 - } + @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 + 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) } - - func writeValue(_ value: YAMLValue) -> [UInt8] { - var bytes = [UInt8]() - self.writeValuePretty(value, into: &bytes) - return bytes + 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 addInset(to bytes: inout [UInt8], depth: Int) { - bytes.append(contentsOf: [UInt8](repeating: ._space, count: depth * YAMLEncoder.singleIndent)) - } + private func writePrettyObject( + _ object: Object, into bytes: inout [UInt8], depth: Int = 0 + ) + where Object.Element == (key: String, value: YAMLValue) { + var iterator = object.makeIterator() - 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) - } - } + while let (key, value) = iterator.next() { + // add a new line when other objects are present already + if bytes.count > 0 { + bytes.append(contentsOf: [._newline]) } - - 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) + // 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() + } } -// self.addInset(to: &bytes, depth: depth) + 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.. 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 ..< nextIndex]) - bytes.append(contentsOf: [._backslash, UInt8(ascii: "/")]) - nextIndex = stringBytes.index(after: nextIndex) - startCopyIndex = nextIndex - default: - nextIndex = stringBytes.index(after: nextIndex) - } - } - - // copy everything, that hasn't been copied yet - bytes.append(contentsOf: stringBytes[startCopyIndex ..< nextIndex]) - } + // copy everything, that hasn't been copied yet + 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)) - } + /// Returns a `.invalidValue` error describing the given invalid floating-point value. + /// + /// + /// - parameter value: The value that was invalid to encode. + /// - parameter path: The path of `CodingKey`s taken to encode this value. + /// - returns: An `EncodingError` with the appropriate path and debug description. + fileprivate static func _invalidFloatingPointValue( + _ 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 string(String) + case number(String) + case bool(Bool) + case null - case array([YAMLValue]) - case object([String: YAMLValue]) + case array([YAMLValue]) + case object([String: YAMLValue]) } -private extension YAMLValue { - var isValue: Bool { - switch self { - case .array, .object: - return false - case .null, .number, .string, .bool: - return true - } - } - - var isContainer: Bool { - switch self { - case .array, .object: - return true - case .null, .number, .string, .bool: - return false - } - } +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 + } + } } -private extension YAMLValue { - 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 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 _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 _colon = UInt8(ascii: ":") + internal static let _comma = UInt8(ascii: ",") - internal static let _openbrace = UInt8(ascii: "{") - internal static let _closebrace = 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 _openbracket = UInt8(ascii: "[") + internal static let _closebracket = UInt8(ascii: "]") - internal static let _quote = UInt8(ascii: "\"") - internal static let _backslash = UInt8(ascii: "\\") + internal static let _quote = UInt8(ascii: "\"") + internal static let _backslash = UInt8(ascii: "\\") - internal static let _dash = 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")] + 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"), + ] } From 3178b8b7ebcfe6c598fb018b5e79201461df325b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 13 Apr 2023 10:35:03 +0200 Subject: [PATCH 68/79] add support for a first set of additional Lambda function properties --- .../DeploymentDescriptor.swift | 776 ++++++----- .../DeploymentDescriptorBuilder.swift | 1150 +++++++++++------ .../DeploymentDescriptorBase.swift | 35 +- .../DeploymentDescriptorBuilderTests.swift | 379 +++++- .../DeploymentDescriptorTests.swift | 232 +++- .../MockedDeploymentDescriptor.swift | 18 +- 6 files changed, 1806 insertions(+), 784 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 508fd954..9866159e 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -22,29 +22,29 @@ import Foundation // 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 = "Transform" - case description = "Description" - case resources = "Resources" - } + 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 {} @@ -53,268 +53,364 @@ public protocol SAMResourceProperties: Encodable {} public enum ResourceType: SAMResourceType { - case type(_ name: String) + case type(_ name: String) - static var serverlessFunction: Self { .type("AWS::Serverless::Function") } - static var queue: Self { .type("AWS::SQS::Queue") } - static var table: Self { .type("AWS::Serverless::SimpleTable") } + static var serverlessFunction: Self { .type("AWS::Serverless::Function") } + static var queue: Self { .type("AWS::SQS::Queue") } + static var table: Self { .type("AWS::Serverless::SimpleTable") } - public func encode(to encoder: Encoder) throws { - if case let .type(value) = self { - var container = encoder.singleValueContainer() - try? container.encode(value) - } + public func encode(to encoder: Encoder) throws { + if case let .type(value) = self { + var container = encoder.singleValueContainer() + try? container.encode(value) } + } } public enum EventSourceType: String, SAMResourceType { - case httpApi = "HttpApi" - case sqs = "SQS" + case httpApi = "HttpApi" + case sqs = "SQS" } // generic type to represent either a top-level resource or an event source public struct Resource: SAMResource, Equatable { - let type: T - let properties: SAMResourceProperties? - let name: String + 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 - } + public static func == (lhs: Resource, rhs: Resource) -> Bool { + lhs.type == rhs.type && lhs.name == rhs.name + } - enum CodingKeys: String, CodingKey { - case type = "Type" - case properties = "Properties" - } + 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) - } + // 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 enum Architectures: String, Encodable, CaseIterable { +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) + #if arch(arm64) return .arm64 -#else // I understand this #else will not always be true. Developers can overwrite the default in Deploy.swift + #else // I understand this #else will not always be true. Developers can overwrite the default in Deploy.swift return .x64 -#endif + #endif } // valid values for error and help message public static func validValues() -> String { - return Architectures.allCases.map { $0.rawValue }.joined(separator: ", ") + return Architectures.allCases.map { $0.rawValue }.joined(separator: ", ") } -} + } -public struct ServerlessFunctionProperties: SAMResourceProperties { - let architectures: [Architectures] - let handler: String - let runtime: String - let codeUri: String? - let autoPublishAlias: String - var eventSources: [String: Resource] - var environment: SAMEnvironmentVariable - - public init( - codeUri: String?, - architecture: Architectures, - eventSources: [Resource] = [], - environment: SAMEnvironmentVariable = .none - ) { - - self.architectures = [architecture] - self.handler = "Provided" - self.runtime = "provided.al2" // Amazon Linux 2 supports both arm64 and x64 - self.autoPublishAlias = "Live" - self.codeUri = codeUri - self.eventSources = [:] - self.environment = environment - - for es in eventSources { - self.eventSources[es.name] = es - } + // 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 + } } - - // custom encoding to not provide Environment variables when there is none - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.architectures, forKey: .architectures) - try container.encode(self.handler, forKey: .handler) - try container.encode(self.runtime, forKey: .runtime) - if let codeUri = self.codeUri { - try container.encode(codeUri, forKey: .codeUri) - } - try container.encode(self.autoPublishAlias, forKey: .autoPublishAlias) - try container.encode(self.eventSources, forKey: .eventSources) - if !environment.isEmpty() { - try container.encode(self.environment, forKey: .environment) - } - } - enum CodingKeys: String, CodingKey { - case architectures = "Architectures" - case handler = "Handler" - case runtime = "Runtime" - case codeUri = "CodeUri" - case autoPublishAlias = "AutoPublishAlias" - case eventSources = "Events" - case environment = "Environment" + case size = "Size" } -} + } -/* - Environment: - Variables: - LOG_LEVEL: debug - */ -public struct SAMEnvironmentVariable: Encodable { + public struct EventInvokeConfiguration: Encodable { + public enum EventInvokeDestinationType: String, Encodable { + case sqs = "SQS" + case sns = "SNS" + case lambda = "Lambda" + case eventBridge = "EventBridge" - 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 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 .serverlessFunction: + return .lambda + default: + return nil + } + } } - public static var none: SAMEnvironmentVariable { return SAMEnvironmentVariable([:]) } - - public static func variable(_ name: String, _ value: String) -> SAMEnvironmentVariable { - return SAMEnvironmentVariable([name: value]) + public struct EventInvokeDestination: Encodable { + let destination: Reference? + let type: EventInvokeDestinationType? } - public static func variable(_ variables: [String: String]) -> SAMEnvironmentVariable { - return SAMEnvironmentVariable(variables) + public struct EventInvokeDestinationConfiguration: Encodable { + let onSuccess: EventInvokeDestination? + let onFailure: EventInvokeDestination? } - public static func variable(_ variables: [[String: String]]) -> SAMEnvironmentVariable { + let destinationConfig: EventInvokeDestinationConfiguration? + let maximumEventAgeInSeconds: Int? + let maximumRetryAttempts: Int? + } - var mergedDictKeepCurrent: [String: String] = [:] - variables.forEach { dict in - // inspired by https://stackoverflow.com/a/43615143/663360 - mergedDictKeepCurrent = mergedDictKeepCurrent.merging(dict) { (current, _) in current } - } + //TODO: add support for reference to other resources of type elasticfilesystem or mountpoint + public struct FileSystemConfig: Encodable { - return SAMEnvironmentVariable(mergedDictKeepCurrent) + // 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 func isEmpty() -> Bool { return variables.count == 0 } + public init?(arn: String, localMountPath: String) { - 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]) - } + guard arn.range(of: validArnRegex, options: .regularExpression) != nil, + localMountPath.range(of: validMountPathRegex, options: .regularExpression) != nil + else { + return nil + } - enum CodingKeys: String, CodingKey { - case variables = "Variables" + 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 + } + } + } +} - public func encode(to encoder: Encoder) throws { - 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]) +/* + Environment: + Variables: + LOG_LEVEL: debug + */ +public struct SAMEnvironmentVariable: Encodable { - // KEY: - // Fn::GetAtt: - // - VALUE1 - // - VALUE2 - case dictionary(value: [String: [String]]) - } + 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) } + 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 - } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: HttpApiKeys.self) - if let method = self.method { - try container.encode(method, forKey: .method) - } - if let path = self.path { - try container.encode(path, forKey: .path) - } - } - let method: HttpVerb? - let path: String? - - enum HttpApiKeys: String, CodingKey { - case method = "Method" - case path = "Path" - } + 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 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 -----------------------------------------------------------------------------------------*/ @@ -322,146 +418,174 @@ public enum HttpVerb: String, Encodable { /// 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 queueByArn: String? - public var queue: Resource? - 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)?.arn { - self.queueByArn = arn - } else { - let logicalName = Resource.logicalName( - resourceType: "Queue", - resourceName: ref) - self.queue = Resource(type: .queue, - properties: SQSResourceProperties(queueName: ref), - name: logicalName) - } - self.batchSize = batchSize - self.enabled = enabled - } - - init(_ queue: Resource, - batchSize: Int, - enabled: Bool) { - - self.queue = queue - self.batchSize = batchSize - self.enabled = enabled - } - - enum CodingKeys: String, CodingKey { - case queue = "Queue" - case batchSize = "BatchSize" - case enabled = "Enabled" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - // 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 - if let queueByArn { - try container.encode(queueByArn, forKey: .queue) - } else if let queue { - var getAttIntrinsicFunction: [String: [String]] = [:] - getAttIntrinsicFunction["Fn::GetAtt"] = [queue.name, "Arn"] - try container.encode(getAttIntrinsicFunction, forKey: .queue) - } - try container.encode(batchSize, forKey: .batchSize) - try container.encode(enabled, forKey: .enabled) - } + 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 - enum CodingKeys: String, CodingKey { - case queueName = "QueueName" - } + 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 - let provisionedThroughput: ProvisionedThroughput? = nil - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try? container.encode(tableName, forKey: .tableName) - try? container.encode(primaryKey, forKey: .primaryKey) - if let provisionedThroughput = self.provisionedThroughput { - try container.encode(provisionedThroughput, forKey: .provisionedThroughput) - } - } - enum CodingKeys: String, CodingKey { - case primaryKey = "PrimaryKey" - case tableName = "TableName" - case provisionedThroughput = "ProvisionedThroughput" - } - struct PrimaryKey: Codable { - let name: String - let type: String - enum CodingKeys: String, CodingKey { - case name = "Name" - case type = "Type" - } - } - struct ProvisionedThroughput: Codable { - let readCapacityUnits: Int - let writeCapacityUnits: Int - enum CodingKeys: String, CodingKey { - case readCapacityUnits = "ReadCapacityUnits" - case writeCapacityUnits = "WriteCapacityUnits" - } - } + 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 -struct Arn { - public let arn: String - init?(_ arn: String) { - // Arn regex from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-eventsourcearn - let arnRegex = - #"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"# - if arn.range(of: arnRegex, options: .regularExpression) != nil { - self.arn = arn - } else { - return nil - } - } +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 - } + // 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 index 823dc164..eba80773 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -22,440 +22,818 @@ private var _deploymentDescriptor: SAMDeploymentDescriptor? @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 { - return expression - } - public static func buildExpression(_ expression: Function) -> [Resource] { - return expression.resources() - } - public static func buildExpression(_ expression: Queue) -> [Resource] { - return [expression.resource()] - } - public static func buildExpression(_ expression: Table) -> [Resource] { - return [expression.resource()] - } - public static func buildExpression(_ expression: Resource) -> [Resource] { - return [expression] - } + // 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 { + return expression + } + public static func buildExpression(_ expression: Function) -> [Resource] { + return expression.resources() + } + public static func buildExpression(_ expression: Queue) -> [Resource] { + return [expression.resource()] + } + public static func buildExpression(_ expression: Table) -> [Resource] { + return [expression.resource()] + } + public static func buildExpression(_ expression: Resource) -> [Resource< + ResourceType + >] { + return [expression] + } } // MARK: Function resource public struct Function { - let name: String - let architecture: Architectures - let codeURI: String? - let eventSources: [Resource] - let environment: [String: String] - - 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, - architecture: Architectures = Architectures.defaultArchitecture(), - codeURI: String?, - eventSources: [Resource] = [], - environment: [String: String] = [:] - ) { - self.name = name - self.architecture = architecture - self.codeURI = codeURI - self.eventSources = eventSources - self.environment = environment - } - public init( - name: String, - architecture: Architectures = Architectures.defaultArchitecture(), - codeURI: String? = nil, - @FunctionBuilder _ builder: () -> (EventSources, [String: String]) - ) { - - let (eventSources, environmentVariables) = builder() - let samEventSource: [Resource] = eventSources.samEventSources() - self.init( - name: name, - architecture: architecture, - codeURI: codeURI, - eventSources: samEventSource, - environment: environmentVariables) - } - - // this method fails when the package does not exist at path - internal func resources() -> [Resource] { - - let properties = ServerlessFunctionProperties( - codeUri: try! packagePath(), - architecture: self.architecture, - eventSources: self.eventSources, - environment: SAMEnvironmentVariable(self.environment)) - - let functionResource = [Resource( - type: .serverlessFunction, - properties: properties, - name: name)] - - let additionalQueueResources = 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 - public func packagePath() 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/\(self.name)/\(self.name).zip" - if let path = self.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)/\(self.name)/\(self.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] { - return self.eventSources - // first filter on event sources of type SQS where the `queue` property is defined (not nil) - .filter { lambdaEventSource in - lambdaEventSource.type == .sqs - && (lambdaEventSource.properties as? SQSEventProperties)?.queue != nil - } - // next extract the queue resource part of the sqsEventSource - .compactMap { - sqsEventSource in (sqsEventSource.properties as? SQSEventProperties)?.queue - } - } - - // MARK: Function DSL code - @resultBuilder - public enum FunctionBuilder { - public static func buildBlock(_ events: EventSources) -> (EventSources, [String: String]) { - return (events, [:]) - } - public static func buildBlock(_ events: EventSources, - _ variables: EnvironmentVariables) -> (EventSources, [String: String]) { - return (events, variables.environmentVariables) - } - public static func buildBlock(_ variables: EnvironmentVariables, - _ events: EventSources) -> (EventSources, [String: String]) { - return (events, variables.environmentVariables) - } - @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") - public static func buildBlock(_ events: EventSources, - _ components: EnvironmentVariables...) -> (EventSources, [String: String]) { - fatalError() + let properties: ServerlessFunctionProperties + let name: String + + 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, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String? = nil, + eventSources: [Resource] = [], + environment: [String: String] = [:], + description: String? = nil + ) { + self.name = name + 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.properties = props + } + private init( + name: String, + properties: ServerlessFunctionProperties + ) { + self.name = name + self.properties = properties + } + public init( + name: String, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String? = nil + ) { + self.name = name + let props = ServerlessFunctionProperties( + codeUri: try! Function.packagePath(name: name, codeUri: codeURI), + architecture: architecture) + self.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 + internal func resources() -> [Resource] { + + let functionResource = [ + Resource( + type: .serverlessFunction, + properties: self.properties, + name: self.name) + ] + + let additionalQueueResources = 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 = self.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? = nil + // should alway be true because of the filer() above + if case let .resource(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( + _ description: String, + _ events: EventSources, + _ components: EnvironmentVariables... + ) -> (String?, EventSources?, [String: String]) { + fatalError() + } + @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") + public static func buildBlock( + _ events: EventSources, + _ components: EnvironmentVariables... + ) -> (String?, EventSources?, [String: String]) { + fatalError() + } + } + + // MARK: function modifiers + public func autoPublishAlias(_ name: String = "live", all: Bool = false, sha256: String? = nil) + -> Function + { + var properties = self.properties + properties.autoPublishAlias = name + properties.autoPublishAliasAllProperties = all + if sha256 != nil { + properties.autoPublishCodeSha256 = sha256 + } else { + properties.autoPublishCodeSha256 = FileDigest.hex(from: self.properties.codeUri) + } + return Function(name: self.name, properties: properties) + } + + public func ephemeralStorage(_ size: Int = 512) -> Function { + var properties = self.properties + properties.ephemeralStorage = ServerlessFunctionProperties.EphemeralStorage(size) + return Function(name: self.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 = self.properties + properties.eventInvokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( + destinationConfig: destination, + maximumEventAgeInSeconds: maximumEventAgeInSeconds, + maximumRetryAttempts: maximumRetryAttempts) + return Function(name: self.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 == .serverlessFunction else { + return self + } + } + + if let onFailure { + guard onFailure.type == .queue || onFailure.type == .serverlessFunction else { + return self + } + } + + let destination = self.getDestinations(onSuccess: onSuccess, onFailure: onFailure) + var properties = self.properties + properties.eventInvokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( + destinationConfig: destination, + maximumEventAgeInSeconds: maximumEventAgeInSeconds, + maximumRetryAttempts: maximumRetryAttempts) + return Function(name: self.name, properties: properties) + } + public func fileSystem(_ arn: String, mountPoint: String) -> Function { + var properties = self.properties + + if let newConfig = ServerlessFunctionProperties.FileSystemConfig( + arn: arn, + localMountPath: mountPoint) + { + + if properties.fileSystemConfigs != nil { + properties.fileSystemConfigs! += [newConfig] + } else { + properties.fileSystemConfigs = [newConfig] + } + + } + return Function(name: self.name, properties: properties) + } + public func urlConfig( + authType: ServerlessFunctionProperties.URLConfig.AuthType = .iam, + invokeMode: ServerlessFunctionProperties.URLConfig.InvokeMode? = nil + ) + -> Function + { + + let builder: () -> [any CorsElement] = { return [] } + return 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 = self.properties + properties.functionUrlConfig = urlConfig + return Function(name: self.name, properties: properties) + } +} +// 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] { + return 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] { + return 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] { + return 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] { + return 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] { + return self._elements.map { $0.rawValue } + } +} +@resultBuilder +public enum CorsBuilder { + public static func buildBlock(_ corsElement: any CorsElement...) -> [any CorsElement] { + return corsElement.compactMap { $0 } + } } // MARK: Event Source public struct EventSources { - private let eventSources: [Resource] - public init(@EventSourceBuilder _ builder: () -> [Resource]) { - self.eventSources = builder() - } - internal func samEventSources() -> [Resource] { - return self.eventSources - } - // MARK: EventSources DSL code - @resultBuilder - public enum EventSourceBuilder { - public static func buildBlock(_ source: Resource...) -> [Resource] { - return source.compactMap { $0 } - } - public static func buildExpression(_ expression: HttpApi) -> Resource { - return expression.resource() - } - public static func buildExpression(_ expression: Sqs) -> Resource { - return expression.resource() - } - public static func buildExpression(_ expression: Resource) -> Resource { - return expression - } - } + 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] { + return self.eventSources + } + // MARK: EventSources DSL code + @resultBuilder + public enum EventSourceBuilder { + public static func buildBlock(_ source: Resource...) -> [Resource< + EventSourceType + >] { + return source.compactMap { $0 } + } + public static func buildExpression(_ expression: HttpApi) -> Resource { + return expression.resource() + } + public static func buildExpression(_ expression: Sqs) -> Resource { + return expression.resource() + } + public static func buildExpression(_ expression: Resource) -> Resource< + EventSourceType + > { + return 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: method, path: path) - } - - return Resource( - type: .httpApi, - properties: properties, - name: name) - } + 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: method, path: path) + } + + return Resource( + type: .httpApi, + properties: properties, + name: 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! = nil - if self.queue != nil { - properties = SQSEventProperties(self.queue!.resource(), - batchSize: self.batchSize, - enabled: self.enabled) - - } else if self.queueRef != nil { - - properties = SQSEventProperties(byRef: self.queueRef!, - batchSize: batchSize, - enabled: enabled) - } else { - fatalError("Either queue or queueRef muts have a value") - } - - return Resource( - type: .sqs, - properties: properties, - name: name) - } + 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! = nil + if self.queue != nil { + properties = SQSEventProperties( + self.queue!.resource(), + batchSize: self.batchSize, + enabled: self.enabled) + + } else if self.queueRef != nil { + + properties = SQSEventProperties( + byRef: self.queueRef!, + batchSize: batchSize, + enabled: enabled) + } else { + fatalError("Either queue or queueRef muts have a value") + } + + return Resource( + type: .sqs, + properties: properties, + name: name) + } } // MARK: Environment Variable public struct EnvironmentVariables { - internal let environmentVariables: [String: String] + internal let environmentVariables: [String: String] - // MARK: EnvironmentVariable DSL code - public init(@EnvironmentVariablesBuilder _ builder: () -> [String: String]) { - self.environmentVariables = builder() - } + // 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] { + @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 - } + // 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 public struct Queue { - let logicalName: String - let physicalName: String - public init(logicalName: String, physicalName: String) { - self.logicalName = logicalName - self.physicalName = physicalName - } - internal func resource() -> Resource { - - let properties = SQSResourceProperties(queueName: self.physicalName) - - return Resource( - type: .queue, - properties: properties, - name: self.logicalName) - } + let logicalName: String + let physicalName: String + public init(logicalName: String, physicalName: String) { + self.logicalName = logicalName + self.physicalName = physicalName + } + internal func resource() -> Resource { + + let properties = SQSResourceProperties(queueName: self.physicalName) + + return Resource( + type: .queue, + properties: properties, + name: self.logicalName) + } } // MARK: Table top level resource public struct Table { - let logicalName: String - let physicalName: String - let primaryKeyName: String - let primaryKeyType: String - public init( - logicalName: String, - physicalName: String, - primaryKeyName: String, - primaryKeyType: String - ) { - - self.logicalName = logicalName - self.physicalName = physicalName - self.primaryKeyName = primaryKeyName - self.primaryKeyType = primaryKeyType - } - internal func resource() -> Resource { + let logicalName: String + let properties: SimpleTableProperties + + private init( + logicalName: String, + properties: SimpleTableProperties + ) { + self.logicalName = logicalName + self.properties = properties + } + + 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) + } + internal func resource() -> Resource { + + return Resource( + type: .table, + properties: self.properties, + name: self.logicalName) + } + + public func provisionedThroughput(readCapacityUnits: Int, writeCapacityUnits: Int) -> Table { + var properties = self.properties + properties.provisionedThroughput = SimpleTableProperties.ProvisionedThroughput( + readCapacityUnits: readCapacityUnits, + writeCapacityUnits: writeCapacityUnits) + return Table( + logicalName: self.logicalName, + properties: properties) + } - let primaryKey = SimpleTableProperties.PrimaryKey(name: primaryKeyName, - type: primaryKeyType) - let properties = SimpleTableProperties(primaryKey: primaryKey, - tableName: physicalName) - return Resource( - type: .table, - properties: properties, - name: logicalName) - } } // 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 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 yaml = try! YAMLEncoder().encode(self) - return String(data: yaml, 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 - } + 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 { + // 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 } + // 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) + 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 @@ -463,11 +841,11 @@ private struct DeploymentDescriptorSerializer { // 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 - } - } + var isRunningXCTest: Bool { + self.threadDictionary.allKeys + .contains { + ($0 as? String)? + .range(of: "XCTest", options: .caseInsensitive) != nil + } + } } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift index 3daf3b46..38c690d4 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBase.swift @@ -15,28 +15,40 @@ @testable import AWSLambdaDeploymentDescriptor import XCTest -private enum TestError: Error { - case canNotCreateDummyPackage(uri: String) -} 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 - codeURI = "\(fileManager.temporaryDirectory.path)/fakeLambda.zip" - if !fileManager.createFile(atPath: codeURI!, - contents: codeURI.data(using: .utf8), - attributes: [.posixPermissions: 0o700]) { - throw TestError.canNotCreateDummyPackage(uri: codeURI) - } + let (_, tempFile) = try self.prepareTemporaryPackageFile() + self.codeURI = tempFile } override func tearDownWithError() throws { // delete the fake lambda package (silently ignore errors) - try? fileManager.removeItem(atPath: codeURI!) - codeURI = nil + 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 @@ -122,7 +134,6 @@ class DeploymentDescriptorBaseTest: XCTestCase { Expected.keyValue(indent: 2, keyValue: ["Type": "AWS::Serverless::Function"]), Expected.keyOnly(indent: 2, key: "Properties"), Expected.keyValue(indent: 3, keyValue: [ - "AutoPublishAlias": "Live", "Handler": "Provided", "CodeUri": self.codeURI, "Runtime": "provided.al2"]), diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift index 67ccdf7b..834888ed 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -20,15 +20,16 @@ import XCTest // 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() - + expectedFunction() + + expectedEnvironmentVariables() + + expectedHttpAPi() + let testDeployment = MockDeploymentDescriptorBuilder( withFunction: true, architecture: .arm64, @@ -36,102 +37,374 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { eventSource: HttpApi().resource(), environmentVariable: ["NAME1": "VALUE1"] ) - + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) - + } - - // check wether the builder creates additional queue resources + + // 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 testFunction = Function(name: "TestFunction", - codeURI: "/path/does/not/exist/lambda.zip") { - EventSources { } - } - - // then - XCTAssertThrowsError(try testFunction.packagePath()) + 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() { - + func testLambdaExistingZIPPackage() throws { + // given - let (tempDir, tempFile) = prepareTemporaryPackageFile() - + 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 - deleteTemporaryPackageFile(tempFile) + XCTAssertNoThrow(try deleteTemporaryPackageFile(tempFile)) } - - private func prepareTemporaryPackageFile() -> (String, String) { - let fm = FileManager.default - let tempDir = fm.temporaryDirectory - let packageDir = MockDeploymentDescriptorBuilder.packageDir() - let packageZip = MockDeploymentDescriptorBuilder.packageZip() - XCTAssertNoThrow(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 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)) } - - private func deleteTemporaryPackageFile(_ file: String) { - let fm = FileManager.default - XCTAssertNoThrow(try fm.removeItem(atPath: file)) + + 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").resource() + 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, + 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").resource() + 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, + 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.resources() + 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.resources() + 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.resources() + 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 testCapacityThroughput() { + // 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 index 8cae5693..871e2190 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -41,13 +41,194 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { func testLambdaFunctionWithSpecificArchitectures() { // given - let expected = [expectedFunction(architecture: Architectures.x64.rawValue), - expectedSAMHeaders()] - .flatMap { $0 } + 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: .serverlessFunction, + 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: .serverlessFunction, + 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: .serverlessFunction, + 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: .serverlessFunction, + properties: functionProperties, + name: functionName) + + // then + let testDeployment = MockDeploymentDescriptor(withFunction: false, + codeURI: self.codeURI, + additionalResources: [ functionToTest ]) XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } @@ -109,7 +290,7 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { // given let expected = expectedSAMHeaders() + - expectedFunction(architecture: Architectures.defaultArchitecture().rawValue) + + expectedFunction(architecture: ServerlessFunctionProperties.Architectures.defaultArchitecture().rawValue) + [ Expected.keyOnly(indent: 4, key: "HttpApiEvent"), Expected.keyValue(indent: 5, keyValue: ["Type": "HttpApi"]) @@ -134,7 +315,7 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { // given let expected = expectedSAMHeaders() + - expectedFunction(architecture: Architectures.defaultArchitecture().rawValue) + + expectedFunction(architecture: ServerlessFunctionProperties.Architectures.defaultArchitecture().rawValue) + [ Expected.keyOnly(indent: 4, key: "HttpApiEvent"), Expected.keyValue(indent: 5, keyValue: ["Type": "HttpApi"]), @@ -291,6 +472,18 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { 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" @@ -313,4 +506,33 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { 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/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index 9b3e7d81..ef5bb96f 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -13,6 +13,7 @@ // ===----------------------------------------------------------------------===// import Foundation +import XCTest @testable import AWSLambdaDeploymentDescriptor protocol MockDeploymentDescriptorBehavior { @@ -25,7 +26,7 @@ struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { let deploymentDescriptor: SAMDeploymentDescriptor init(withFunction: Bool = true, - architecture: Architectures = Architectures.defaultArchitecture(), + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), codeURI: String, eventSource: [Resource]? = nil, environmentVariable: SAMEnvironmentVariable? = nil, @@ -67,8 +68,21 @@ struct MockDeploymentDescriptorBuilder: MockDeploymentDescriptorBehavior { static let functionName = "TestLambda" let deploymentDescriptor: DeploymentDescriptor + init(withResource function: Function) { + XCTAssert(function.resources().count == 1) + self.init(withResource: function.resources()[0]) + } + init(withResource table: Table) { + self.init(withResource: table.resource()) + } + init(withResource: Resource) { + self.deploymentDescriptor = DeploymentDescriptor { + "A SAM template to deploy a Swift Lambda function" + withResource + } + } init(withFunction: Bool = true, - architecture: Architectures = Architectures.defaultArchitecture(), + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), codeURI: String, eventSource: Resource, environmentVariable: [String: String]) { From 638fd237da876105d844de1a2142cb7b303b14b4 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 13 Apr 2023 10:37:37 +0200 Subject: [PATCH 69/79] add example with URL invocation --- Examples/SAM/Deploy.swift | 49 +++++++++++++++++++------ Examples/SAM/Package.swift | 8 +++++ Examples/SAM/UrlLambda/Lambda.swift | 55 +++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 Examples/SAM/UrlLambda/Lambda.swift diff --git a/Examples/SAM/Deploy.swift b/Examples/SAM/Deploy.swift index 1c05a0a4..ecb9aa99 100644 --- a/Examples/SAM/Deploy.swift +++ b/Examples/SAM/Deploy.swift @@ -8,15 +8,21 @@ let sharedQueue = 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" - // a lambda function + // 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 @@ -30,7 +36,7 @@ DeploymentDescriptor { EnvironmentVariables { [ "NAME1": "VALUE1", - "NAME2": "VALUE2" + "NAME2": "VALUE2", ] // shared environment variables declared upfront @@ -38,7 +44,30 @@ DeploymentDescriptor { } } - // create a Lambda function and its depending resources + // 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 { @@ -53,31 +82,31 @@ DeploymentDescriptor { // 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 + // // 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 + // Additional resources // - // create a SAS queue + // Create a SQS queue Queue( logicalName: "TopLevelQueueResource", physicalName: "swift-lambda-top-level-queue") - // create a DynamoDB table + // 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/Package.swift b/Examples/SAM/Package.swift index 3ba60d26..d21b531e 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -39,6 +39,14 @@ let package = Package( ], 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: [ 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)") + + } + } +} From 4d77e3629d1f0ed30a8de23b6fc26b8d9f7531cf Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 11 Sep 2023 11:01:32 -0500 Subject: [PATCH 70/79] remove unused key converter and respect key converter strategy for string-object dictionary --- .../YAMLEncoder.swift | 24 +++++++++---------- .../YAMLEncoderTests.swift | 4 ++-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift index b7e7b8a6..967ecc40 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/YAMLEncoder.swift @@ -18,7 +18,7 @@ import Foundation /// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary` -/// containing `Encodable` values (in which case it should be exempt from key conversion strategies). +/// containing `Encodable` values /// private protocol _YAMLStringDictionaryEncodableMarker {} @@ -107,15 +107,10 @@ open class YAMLEncoder { /// Use the keys specified by each type. This is the default strategy. case useDefaultKeys - /// convert keyname to camelcase. + /// convert keyname to camel case. /// for example myMaxValue becomes MyMaxValue case camelCase - /// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types. - /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. - /// If the result of the conversion is a duplicate key, then only one value will be present in the result. - case custom((_ codingPath: [CodingKey]) -> CodingKey) - fileprivate static func _convertToCamelCase(_ stringKey: String) -> String { return stringKey.prefix(1).capitalized + stringKey.dropFirst() } @@ -536,10 +531,15 @@ extension _SpecialTreatmentEncoder { try object.forEach { (key, value) in var elemCodingPath = baseCodingPath - elemCodingPath.append(_YAMLKey(stringValue: key, intValue: nil)) + elemCodingPath.append(_YAMLKey(stringValue:key)) + let encoder = YAMLEncoderImpl(options: self.options, codingPath: elemCodingPath) - result[key] = try encoder.wrapUntyped(value) + var convertedKey = key + if self.options.keyEncodingStrategy == .camelCase { + convertedKey = YAMLEncoder.KeyEncodingStrategy._convertToCamelCase(key) + } + result[convertedKey] = try encoder.wrapUntyped(value) } return .object(result) @@ -590,8 +590,6 @@ private struct YAMLKeyedEncodingContainer: KeyedEncodingContainerP case .camelCase: let newKeyString = YAMLEncoder.KeyEncodingStrategy._convertToCamelCase(key.stringValue) return _YAMLKey(stringValue: newKeyString, intValue: key.intValue) - case .custom(let converter): - return converter(codingPath + [key]) } } @@ -1107,7 +1105,7 @@ internal struct _YAMLKey: CodingKey { public var stringValue: String public var intValue: Int? - public init?(stringValue: String) { + public init(stringValue: String) { self.stringValue = stringValue self.intValue = nil } @@ -1127,7 +1125,7 @@ internal struct _YAMLKey: CodingKey { self.intValue = index } - internal static let `super` = _YAMLKey(stringValue: "super")! + internal static let `super` = _YAMLKey(stringValue: "super") } // ===----------------------------------------------------------------------===// diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift index 33993981..94aa5234 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/YAMLEncoderTests.swift @@ -708,8 +708,8 @@ class TestYAMLEncoder: XCTestCase { return } print(yaml) - XCTAssertTrue(yaml.contains("camelCaseKey:")) - XCTAssertTrue(yaml.contains(" nestedDictionary: 1")) + XCTAssertTrue(yaml.contains("CamelCaseKey:")) + XCTAssertTrue(yaml.contains(" NestedDictionary: 1")) } func test_OutputFormattingValues() { From 3e8b87f17b2c244cc2b233c436a221569c690969 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 12 Sep 2023 16:44:39 -0700 Subject: [PATCH 71/79] add deployer plugin target to Swift 5.7 and Swift 5.8 --- Package@swift-5.7.swift | 17 +++++++++++++++++ Package@swift-5.8.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) 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"), From edec15dab10de593e24a539436d6685986893f27 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 16 Apr 2024 15:02:24 +0200 Subject: [PATCH 72/79] simplifiy the management of resource type --- .../DeploymentDescriptor.swift | 34 +++++++++++-------- .../DeploymentDescriptorBuilder.swift | 10 +++--- .../DeploymentDescriptorBuilderTests.swift | 20 +++++++++++ .../DeploymentDescriptorTests.swift | 8 ++--- .../MockedDeploymentDescriptor.swift | 2 +- 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 9866159e..72156e1e 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -47,24 +47,30 @@ public struct SAMDeploymentDescriptor: Encodable { } } -public protocol SAMResource: Encodable {} +public protocol SAMResource: Encodable, Equatable {} public protocol SAMResourceType: Encodable, Equatable {} public protocol SAMResourceProperties: Encodable {} -public enum ResourceType: SAMResourceType { +// public enum ResourceType: SAMResourceType { - case type(_ name: String) +// case type(_ name: String) - static var serverlessFunction: Self { .type("AWS::Serverless::Function") } - static var queue: Self { .type("AWS::SQS::Queue") } - static var table: Self { .type("AWS::Serverless::SimpleTable") } +// static var serverlessFunction: Self { .type("AWS::Serverless::Function") } +// static var queue: Self { .type("AWS::SQS::Queue") } +// static var table: Self { .type("AWS::Serverless::SimpleTable") } - public func encode(to encoder: Encoder) throws { - if case let .type(value) = self { - var container = encoder.singleValueContainer() - try? container.encode(value) - } - } +// public func encode(to encoder: Encoder) throws { +// if case let .type(value) = self { +// var container = encoder.singleValueContainer() +// try? container.encode(value) +// } +// } +// } + +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 { @@ -73,7 +79,7 @@ public enum EventSourceType: String, SAMResourceType { } // generic type to represent either a top-level resource or an event source -public struct Resource: SAMResource, Equatable { +public struct Resource: SAMResource { let type: T let properties: SAMResourceProperties? @@ -176,7 +182,7 @@ public struct ServerlessFunctionProperties: SAMResourceProperties { switch res.type { case .queue: return .sqs - case .serverlessFunction: + case .function: return .lambda default: return nil diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index eba80773..e699b085 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -170,7 +170,7 @@ public struct Function { let functionResource = [ Resource( - type: .serverlessFunction, + type: .function, properties: self.properties, name: self.name) ] @@ -407,13 +407,13 @@ public struct Function { ) -> Function { if let onSuccess { - guard onSuccess.type == .queue || onSuccess.type == .serverlessFunction else { + guard onSuccess.type == .queue || onSuccess.type == .function else { return self } } if let onFailure { - guard onFailure.type == .queue || onFailure.type == .serverlessFunction else { + guard onFailure.type == .queue || onFailure.type == .function else { return self } } @@ -739,6 +739,8 @@ public struct Queue { } // MARK: Table top level resource +// public typealias Table = Resource where ResourceType == .table + public struct Table { let logicalName: String let properties: SimpleTableProperties @@ -782,9 +784,9 @@ public struct Table { logicalName: self.logicalName, properties: properties) } - } + // MARK: Serialization code extension SAMDeploymentDescriptor { diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift index 834888ed..eed98ec9 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -384,6 +384,26 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { } //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 testCapacityThroughput() { // given diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift index 871e2190..0a07078f 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorTests.swift @@ -75,7 +75,7 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { functionProperties.autoPublishCodeSha256 = "sha256" functionProperties.description = "my function description" functionProperties.ephemeralStorage = ServerlessFunctionProperties.EphemeralStorage(1024) - let functionToTest = Resource(type: .serverlessFunction, + let functionToTest = Resource(type: .function, properties: functionProperties, name: functionName) @@ -116,7 +116,7 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { maximumEventAgeInSeconds: 999, maximumRetryAttempts: 33) functionProperties.eventInvokeConfig = invokeConfig - let functionToTest = Resource(type: .serverlessFunction, + let functionToTest = Resource(type: .function, properties: functionProperties, name: functionName) @@ -153,7 +153,7 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { XCTFail("Invalid Arn or MountPoint") } - let functionToTest = Resource(type: .serverlessFunction, + let functionToTest = Resource(type: .function, properties: functionProperties, name: functionName) @@ -221,7 +221,7 @@ final class DeploymentDescriptorTests: DeploymentDescriptorBaseTest { invokeMode: .buffered) functionProperties.functionUrlConfig = config - let functionToTest = Resource(type: .serverlessFunction, + let functionToTest = Resource(type: .function, properties: functionProperties, name: functionName) diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index ef5bb96f..339d4367 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -39,7 +39,7 @@ struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { eventSources: eventSource ?? [], environment: environmentVariable ?? SAMEnvironmentVariable.none) let serverlessFunction = Resource( - type: .serverlessFunction, + type: .function, properties: properties, name: "TestLambda") From a2afbf126bd5563faf62047cd2d10dfc1cbcda40 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 16 Apr 2024 15:43:24 +0200 Subject: [PATCH 73/79] add builder tests --- .../DeploymentDescriptorBuilderTests.swift | 15 ++++++++++++++- .../MockedDeploymentDescriptor.swift | 8 ++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift index eed98ec9..957a35ba 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -43,6 +43,19 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { } + func testQueueResource() { + + // given + let expected = expectedQueue() + + let queue = Queue(logicalName: "QueueTestQueue", physicalName: "test-queue").resource() + + let testDeployment = MockDeploymentDescriptorBuilder(withResource: queue) + + XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, + expected: expected)) + } + // check wether the builder creates additional queue resources func testLambdaCreateAdditionalResourceWithName() { @@ -405,7 +418,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { XCTAssertTrue(self.generateAndTestDeploymentDescriptor(deployment: testDeployment, expected: expected)) } - func testCapacityThroughput() { + func testSimpleTableCapacityThroughput() { // given let writeCapacity = 999 let readCapacity = 666 diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index 339d4367..126c2797 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -27,10 +27,10 @@ struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { init(withFunction: Bool = true, architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), - codeURI: String, + codeURI: String = "", eventSource: [Resource]? = nil, environmentVariable: SAMEnvironmentVariable? = nil, - additionalResources: [Resource]? = nil) { + additionalResources: [Resource] = []) { if withFunction { let properties = ServerlessFunctionProperties( @@ -45,13 +45,13 @@ struct MockDeploymentDescriptor: MockDeploymentDescriptorBehavior { self.deploymentDescriptor = SAMDeploymentDescriptor( description: "A SAM template to deploy a Swift Lambda function", - resources: [ serverlessFunction ] + (additionalResources ?? []) + resources: [ serverlessFunction ] + additionalResources ) } else { self.deploymentDescriptor = SAMDeploymentDescriptor( description: "A SAM template to deploy a Swift Lambda function", - resources: (additionalResources ?? []) + resources: additionalResources ) } } From 4a162a1e55fccf19cc346fd3c0fc8534c9473471 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 16 Apr 2024 18:06:05 +0200 Subject: [PATCH 74/79] further simplification for resultBuilder types --- .../DeploymentDescriptorBuilder.swift | 1517 +++++++++-------- .../DeploymentDescriptorBuilderTests.swift | 16 +- .../MockedDeploymentDescriptor.swift | 13 +- 3 files changed, 784 insertions(+), 762 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index e699b085..82dc65e0 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -20,834 +20,861 @@ private var _deploymentDescriptor: SAMDeploymentDescriptor? // a top level DeploymentDescriptor DSL @resultBuilder -public struct DeploymentDescriptor { +struct DeploymentDescriptor { + // capture the deployment descriptor for unit tests + let samDeploymentDescriptor: SAMDeploymentDescriptor - // capture the deployment descriptor for unit tests - let samDeploymentDescriptor: SAMDeploymentDescriptor + // MARK: Generation of the SAM Deployment Descriptor - // 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) + } + } - private init( - description: String = "A SAM template to deploy a Swift Lambda function", - resources: [Resource] - ) { + // MARK: resultBuilder specific code - self.samDeploymentDescriptor = SAMDeploymentDescriptor( - description: description, - resources: resources - ) + // this initializer allows to declare a top level `DeploymentDescriptor { }`` + @discardableResult + public init(@DeploymentDescriptor _ builder: () -> DeploymentDescriptor) { + self = builder() + } - // 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 { - return expression - } - public static func buildExpression(_ expression: Function) -> [Resource] { - return expression.resources() - } - public static func buildExpression(_ expression: Queue) -> [Resource] { - return [expression.resource()] - } - public static func buildExpression(_ expression: Table) -> [Resource] { - return [expression.resource()] - } - public static func buildExpression(_ expression: Resource) -> [Resource< - ResourceType - >] { - return [expression] - } + 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 + } + + static func buildExpression(_ expression: any BuilderResource) -> [Resource] { + expression.resource() + } + +} + +internal protocol BuilderResource { + func resource() -> [Resource] } // MARK: Function resource -public struct Function { - - let properties: ServerlessFunctionProperties - let name: String - - 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, - architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), - codeURI: String? = nil, - eventSources: [Resource] = [], - environment: [String: String] = [:], - description: String? = nil - ) { - self.name = name - 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.properties = props - } - private init( - name: String, - properties: ServerlessFunctionProperties - ) { - self.name = name - self.properties = properties - } - public init( - name: String, - architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), - codeURI: String? = nil - ) { - self.name = name - let props = ServerlessFunctionProperties( - codeUri: try! Function.packagePath(name: name, codeUri: codeURI), - architecture: architecture) - self.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 - internal func resources() -> [Resource] { - - let functionResource = [ - Resource( - type: .function, - properties: self.properties, - name: self.name) - ] - - let additionalQueueResources = 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 = self.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? = nil - // should alway be true because of the filer() above - if case let .resource(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 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" + } + } } - public static func buildBlock( - _ description: String, - _ events: EventSources - ) -> (String?, EventSources, [String: String]) { - return (description, events, [:]) + + 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 static func buildBlock(_ events: EventSources) -> ( - String?, EventSources, [String: String] + + public init( + name: String, + architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), + codeURI: String? = nil ) { - return (nil, events, [:]) + 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 + internal 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 } - public static func buildBlock( - _ description: String, - _ events: EventSources, - _ variables: EnvironmentVariables - ) -> (String?, EventSources, [String: String]) { - return (description, events, variables.environmentVariables) + + // 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() + } } - public static func buildBlock( - _ events: EventSources, - _ variables: EnvironmentVariables - ) -> (String?, EventSources, [String: String]) { - return (nil, events, variables.environmentVariables) + + // 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 static func buildBlock( - _ description: String, - _ variables: EnvironmentVariables, - _ events: EventSources - ) -> (String?, EventSources, [String: String]) { - return (description, events, variables.environmentVariables) + + public func ephemeralStorage(_ size: Int = 512) -> Function { + var properties = properties() + properties.ephemeralStorage = ServerlessFunctionProperties.EphemeralStorage(size) + return Function(name(), properties: properties) } - public static func buildBlock( - _ variables: EnvironmentVariables, - _ events: EventSources - ) -> (String?, EventSources, [String: String]) { - return (nil, events, variables.environmentVariables) + + 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 + ) } - @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") - public static func buildBlock( - _ description: String, - _ events: EventSources, - _ components: EnvironmentVariables... - ) -> (String?, EventSources?, [String: String]) { - fatalError() + + 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) } - @available(*, unavailable, message: "Only one EnvironmentVariables block is allowed") - public static func buildBlock( - _ events: EventSources, - _ components: EnvironmentVariables... - ) -> (String?, EventSources?, [String: String]) { - fatalError() - } - } - - // MARK: function modifiers - public func autoPublishAlias(_ name: String = "live", all: Bool = false, sha256: String? = nil) - -> Function - { - var properties = self.properties - properties.autoPublishAlias = name - properties.autoPublishAliasAllProperties = all - if sha256 != nil { - properties.autoPublishCodeSha256 = sha256 - } else { - properties.autoPublishCodeSha256 = FileDigest.hex(from: self.properties.codeUri) - } - return Function(name: self.name, properties: properties) - } - - public func ephemeralStorage(_ size: Int = 512) -> Function { - var properties = self.properties - properties.ephemeralStorage = ServerlessFunctionProperties.EphemeralStorage(size) - return Function(name: self.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 = self.properties - properties.eventInvokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( - destinationConfig: destination, - maximumEventAgeInSeconds: maximumEventAgeInSeconds, - maximumRetryAttempts: maximumRetryAttempts) - return Function(name: self.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 = self.properties - properties.eventInvokeConfig = ServerlessFunctionProperties.EventInvokeConfiguration( - destinationConfig: destination, - maximumEventAgeInSeconds: maximumEventAgeInSeconds, - maximumRetryAttempts: maximumRetryAttempts) - return Function(name: self.name, properties: properties) - } - public func fileSystem(_ arn: String, mountPoint: String) -> Function { - var properties = self.properties - - if let newConfig = ServerlessFunctionProperties.FileSystemConfig( - arn: arn, - localMountPath: mountPoint) - { - - if properties.fileSystemConfigs != nil { - properties.fileSystemConfigs! += [newConfig] - } else { - properties.fileSystemConfigs = [newConfig] - } - - } - return Function(name: self.name, properties: properties) - } - public func urlConfig( - authType: ServerlessFunctionProperties.URLConfig.AuthType = .iam, - invokeMode: ServerlessFunctionProperties.URLConfig.InvokeMode? = nil - ) - -> Function - { - - let builder: () -> [any CorsElement] = { return [] } - return urlConfig( - authType: authType, - invokeMode: invokeMode, - allowCredentials: nil, - maxAge: nil, - builder + + public func urlConfig( + authType: ServerlessFunctionProperties.URLConfig.AuthType = .iam, + invokeMode: ServerlessFunctionProperties.URLConfig.InvokeMode? = nil ) - } - - 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 = self.properties - properties.functionUrlConfig = urlConfig - return Function(name: self.name, properties: properties) - } + -> 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 + 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]) + 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] { - return header.compactMap { $0 } - } + 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] { - return self._elements - } + 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] { - return self._elements - } + 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] { - return self._elements - } + 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] { - return self._elements.map { $0.rawValue } - } + 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] { - return corsElement.compactMap { $0 } - } + 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] { - return self.eventSources - } - // MARK: EventSources DSL code - @resultBuilder - public enum EventSourceBuilder { - public static func buildBlock(_ source: Resource...) -> [Resource< - EventSourceType - >] { - return source.compactMap { $0 } - } - public static func buildExpression(_ expression: HttpApi) -> Resource { - return expression.resource() - } - public static func buildExpression(_ expression: Sqs) -> Resource { - return expression.resource() - } - public static func buildExpression(_ expression: Resource) -> Resource< - EventSourceType - > { - return expression - } - } + 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: method, path: path) - } - - return Resource( - type: .httpApi, - properties: properties, - name: name) - } + 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! = nil - if self.queue != nil { - properties = SQSEventProperties( - self.queue!.resource(), - batchSize: self.batchSize, - enabled: self.enabled) - - } else if self.queueRef != nil { - - properties = SQSEventProperties( - byRef: self.queueRef!, - batchSize: batchSize, - enabled: enabled) - } else { - fatalError("Either queue or queueRef muts have a value") - } - - return Resource( - type: .sqs, - properties: properties, - name: name) - } + 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] +public struct EnvironmentVariables { + internal let environmentVariables: [String: String] - // MARK: EnvironmentVariable DSL code - public init(@EnvironmentVariablesBuilder _ builder: () -> [String: String]) { - self.environmentVariables = builder() - } + // MARK: EnvironmentVariable DSL code - @resultBuilder - public enum EnvironmentVariablesBuilder { - public static func buildBlock(_ variables: [String: String]...) -> [String: String] { + public init(@EnvironmentVariablesBuilder _ builder: () -> [String: String]) { + self.environmentVariables = builder() + } - // 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 + @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 -public struct Queue { - let logicalName: String - let physicalName: String - public init(logicalName: String, physicalName: String) { - self.logicalName = logicalName - self.physicalName = physicalName - } - internal func resource() -> Resource { - - let properties = SQSResourceProperties(queueName: self.physicalName) - - return Resource( - type: .queue, - properties: properties, - name: self.logicalName) - } +//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 + ) + } + + internal func resource() -> [Resource] { [_underlying] } } // MARK: Table top level resource -// public typealias Table = Resource where ResourceType == .table - -public struct Table { - let logicalName: String - let properties: SimpleTableProperties - - private init( - logicalName: String, - properties: SimpleTableProperties - ) { - self.logicalName = logicalName - self.properties = properties - } - - 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) - } - internal func resource() -> Resource { - - return Resource( - type: .table, - properties: self.properties, - name: self.logicalName) - } - - public func provisionedThroughput(readCapacityUnits: Int, writeCapacityUnits: Int) -> Table { - var properties = self.properties - properties.provisionedThroughput = SimpleTableProperties.ProvisionedThroughput( - readCapacityUnits: readCapacityUnits, - writeCapacityUnits: writeCapacityUnits) - return Table( - logicalName: self.logicalName, - properties: properties) - } +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) + } + + internal 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] + 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)! } - 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) + internal func toYAML() -> String { + let encoder = YAMLEncoder() + encoder.keyEncodingStrategy = .camelCase + let yaml = try! encoder.encode(self) - return String(data: yaml, encoding: .utf8)! - } + 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) + } - 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) } - - 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 - } - } + var isRunningXCTest: Bool { + self.threadDictionary.allKeys + .contains { + ($0 as? String)? + .range(of: "XCTest", options: .caseInsensitive) != nil + } + } } diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift index 957a35ba..5318351b 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/DeploymentDescriptorBuilderTests.swift @@ -48,7 +48,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { // given let expected = expectedQueue() - let queue = Queue(logicalName: "QueueTestQueue", physicalName: "test-queue").resource() + let queue = Queue(logicalName: "QueueTestQueue", physicalName: "test-queue") let testDeployment = MockDeploymentDescriptorBuilder(withResource: queue) @@ -210,7 +210,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { func testeventInvokeConfigWithSuccessQueue() { // given - let queue1 = Queue(logicalName: "queue1", physicalName: "queue1").resource() + let queue1 = Queue(logicalName: "queue1", physicalName: "queue1") let expected = [ Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), Expected.keyValue(indent: 4, keyValue: ["MaximumEventAgeInSeconds": "900", @@ -226,7 +226,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { // when let function = Function(name: functionName, codeURI: self.codeURI) - .eventInvoke(onSuccess: queue1, + .eventInvoke(onSuccess: queue1.resource()[0], onFailure: nil, maximumEventAgeInSeconds: 900, maximumRetryAttempts: 3) @@ -239,7 +239,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { func testeventInvokeConfigWithFailureQueue() { // given - let queue1 = Queue(logicalName: "queue1", physicalName: "queue1").resource() + let queue1 = Queue(logicalName: "queue1", physicalName: "queue1") let expected = [ Expected.keyOnly(indent: 3, key: "EventInvokeConfig"), Expected.keyValue(indent: 4, keyValue: ["MaximumEventAgeInSeconds": "900", @@ -256,7 +256,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { // when let function = Function(name: functionName, codeURI: self.codeURI) .eventInvoke(onSuccess: nil, - onFailure: queue1, + onFailure: queue1.resource()[0], maximumEventAgeInSeconds: 900, maximumRetryAttempts: 3) @@ -283,7 +283,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { // when var function = Function(name: functionName, codeURI: self.codeURI) - let resource = function.resources() + let resource = function.resource() XCTAssertTrue(resource.count == 1) function = function.eventInvoke(onSuccess: resource[0], onFailure: nil, @@ -320,7 +320,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { // when var function = Function(name: functionName, codeURI: self.codeURI) - let resource = function.resources() + let resource = function.resource() XCTAssertTrue(resource.count == 1) function = function.urlConfig(authType: .iam, invokeMode: .buffered, @@ -360,7 +360,7 @@ final class DeploymentDescriptorBuilderTests: DeploymentDescriptorBaseTest { // when var function = Function(name: functionName, codeURI: self.codeURI) - let resource = function.resources() + let resource = function.resource() XCTAssertTrue(resource.count == 1) function = function.urlConfig(authType: .iam, invokeMode: .buffered) diff --git a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift index 126c2797..5ec56b40 100644 --- a/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift +++ b/Tests/AWSLambdaDeploymentDescriptorTests/MockedDeploymentDescriptor.swift @@ -68,19 +68,14 @@ struct MockDeploymentDescriptorBuilder: MockDeploymentDescriptorBehavior { static let functionName = "TestLambda" let deploymentDescriptor: DeploymentDescriptor - init(withResource function: Function) { - XCTAssert(function.resources().count == 1) - self.init(withResource: function.resources()[0]) - } - init(withResource table: Table) { - self.init(withResource: table.resource()) - } - init(withResource: Resource) { + init(withResource resource: any BuilderResource) { + self.deploymentDescriptor = DeploymentDescriptor { "A SAM template to deploy a Swift Lambda function" - withResource + resource } } + init(withFunction: Bool = true, architecture: ServerlessFunctionProperties.Architectures = .defaultArchitecture(), codeURI: String, From 4432b02d2e041beaf27a62dfa5742fa406c80a8f Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 13 May 2024 11:49:58 +0200 Subject: [PATCH 75/79] add local directory to dependency --- Examples/SAM/Package.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index d21b531e..ad41d6cc 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -24,10 +24,11 @@ let package = Package( ], products: [ .executable(name: "HttpApiLambda", targets: ["HttpApiLambda"]), - .executable(name: "SQSLambda", targets: ["SQSLambda"]) + .executable(name: "SQSLambda", targets: ["SQSLambda"]), + .executable(name: "UrlLambda", targets: ["UrlLambda"]) ], dependencies: [ - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha"), + .package(url: "../../../swift-aws-lambda-runtime", branch: "sebsto/deployerplugin_dsl"), .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main") ], targets: [ From d65f0505a4cef1b18d3aad5664da23f11ff85d0e Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 13 May 2024 12:14:45 +0200 Subject: [PATCH 76/79] removed unused code --- .../DeploymentDescriptor.swift | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift index 72156e1e..16e6b5cc 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptor.swift @@ -51,22 +51,6 @@ public protocol SAMResource: Encodable, Equatable {} public protocol SAMResourceType: Encodable, Equatable {} public protocol SAMResourceProperties: Encodable {} -// public enum ResourceType: SAMResourceType { - -// case type(_ name: String) - -// static var serverlessFunction: Self { .type("AWS::Serverless::Function") } -// static var queue: Self { .type("AWS::SQS::Queue") } -// static var table: Self { .type("AWS::Serverless::SimpleTable") } - -// public func encode(to encoder: Encoder) throws { -// if case let .type(value) = self { -// var container = encoder.singleValueContainer() -// try? container.encode(value) -// } -// } -// } - public enum ResourceType: String, SAMResourceType { case function = "AWS::Serverless::Function" case queue = "AWS::SQS::Queue" From ea66930db044063d83e163f7149a2426000c4961 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Mon, 13 May 2024 14:03:35 +0200 Subject: [PATCH 77/79] fix public access for some methods / struct --- .../DeploymentDescriptorBuilder.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift index 82dc65e0..49bf7c37 100644 --- a/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift +++ b/Sources/AWSLambdaDeploymentDescriptor/DeploymentDescriptorBuilder.swift @@ -20,7 +20,7 @@ private var _deploymentDescriptor: SAMDeploymentDescriptor? // a top level DeploymentDescriptor DSL @resultBuilder -struct DeploymentDescriptor { +public struct DeploymentDescriptor { // capture the deployment descriptor for unit tests let samDeploymentDescriptor: SAMDeploymentDescriptor @@ -76,13 +76,13 @@ struct DeploymentDescriptor { expression } - static func buildExpression(_ expression: any BuilderResource) -> [Resource] { + public static func buildExpression(_ expression: any BuilderResource) -> [Resource] { expression.resource() } } -internal protocol BuilderResource { +public protocol BuilderResource { func resource() -> [Resource] } @@ -163,7 +163,7 @@ public struct Function: BuilderResource { } // this method fails when the package does not exist at path - internal func resource() -> [Resource] { + public func resource() -> [Resource] { let functionResource = [ self._underlying ] let additionalQueueResources = self.collectQueueResources() @@ -767,7 +767,7 @@ public struct Queue: BuilderResource { ) } - internal func resource() -> [Resource] { [_underlying] } + public func resource() -> [Resource] { [_underlying] } } // MARK: Table top level resource @@ -801,7 +801,7 @@ public struct Table: BuilderResource { self.init(logicalName: logicalName, properties: properties) } - internal func resource() -> [Resource] { [ self._underlying ] } + 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 From bddc2a11bfbadccbf4ea18ba1bd15e7c65440b13 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 15 May 2024 10:07:31 +0200 Subject: [PATCH 78/79] support LAMBDA_USE_LOCAL_DEPS --- Examples/SAM/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/SAM/Package.swift b/Examples/SAM/Package.swift index ad41d6cc..8218335c 100644 --- a/Examples/SAM/Package.swift +++ b/Examples/SAM/Package.swift @@ -28,7 +28,7 @@ let package = Package( .executable(name: "UrlLambda", targets: ["UrlLambda"]) ], dependencies: [ - .package(url: "../../../swift-aws-lambda-runtime", branch: "sebsto/deployerplugin_dsl"), + .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: [ From b26dccc0557a19e9a87c029742ffb314b9dbb3f0 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 22 May 2024 15:36:51 +0200 Subject: [PATCH 79/79] revert plugin execute to Utils --- Plugins/AWSLambdaPackager/Plugin.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift index a6f0efe6..e03a389d 100644 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ b/Plugins/AWSLambdaPackager/Plugin.swift @@ -120,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