diff --git a/Sources/AWSLambdaRuntimeCore/Documentation.docc/Proposals/0001-v2-api.md b/Sources/AWSLambdaRuntimeCore/Documentation.docc/Proposals/0001-v2-api.md new file mode 100644 index 00000000..e4ff259b --- /dev/null +++ b/Sources/AWSLambdaRuntimeCore/Documentation.docc/Proposals/0001-v2-api.md @@ -0,0 +1,905 @@ +# v2 API proposal for swift-aws-lambda-runtime + +`swift-aws-lambda-runtime` is an important library for the Swift on Server ecosystem. The initial API was written before +async/await was introduced to Swift. When async/await was introduced, shims were added to bridge between the underlying +SwiftNIO `EventLoop` interfaces and async/await. However, just like `gRPC-swift` and `postgres-nio`, we now want to +shift to solely using async/await instead of `EventLoop` interfaces. For this, large parts of the current API have to be +reconsidered. + +## Overview + +Versions: + +- v1 (2024-08-07): Initial version +- v1.1: + - Remove the `reportError(_:)` method from `LambdaResponseStreamWriter` and instead make the `handle(...)` method of + `StreamingLambdaHandler` throwing. + - Remove the `addBackgroundTask(_:)` method from `LambdaContext` due to structured concurrency concerns and introduce + the `LambdaWithBackgroundProcessingHandler` protocol as a solution. + - Introduce `LambdaHandlerAdapter`, which adapts handlers conforming to `LambdaHandler` with + `LambdaWithBackgroundProcessingHandler`. + - Update `LambdaCodableAdapter` to now be generic over any handler conforming to + `LambdaWithBackgroundProcessingHandler` instead of `LambdaHandler`. +- v1.2: + - Remove `~Copyable` from `LambdaResponseStreamWriter` and `LambdaResponseWriter`. Instead throw an error when + `finish()` is called multiple times or when `write`/`writeAndFinish` is called after `finish()`. + +## Motivation + +### Current Limitations + +#### EventLoop interfaces + +The current API extensively uses the `EventLoop` family of interfaces from SwiftNIO in many areas. To use these +interfaces correctly though, it requires developers to exercise great care and understand the various transform methods +that are used to work with `EventLoop`s and `EventLoopFuture`s. This results in a lot of cognitive complexity and makes +the code in the current API hard to reason about and maintain. For these reasons, the overarching trend in the Swift on +Server ecosystem is to shift to newer, more readable, Swift concurrency constructs and de-couple from SwiftNIO's +`EventLoop` interfaces. + +#### No ownership of the main() function + +A Lambda function can currently be implemented through conformance to the various handler protocols defined in +`AWSLambdaRuntimeCore/LambdaHandler`. Each of these protocols have an extension which implements a `static func main()`. +This allows users to annotate their `LambdaHandler` conforming object with `@main`. The `static func main()` calls the +internal `Lambda.run()` function, which starts the Lambda function. Since the `Lambda.run()` method is internal, users +cannot override the default implementation. This has proven challenging for users who want to +[set up global properties before the Lambda starts-up](https://github.com/swift-server/swift-aws-lambda-runtime/issues/265). +Setting up global properties is required to customize the Swift Logging, Metric and Tracing backend. + +#### Non-trivial transition from SimpleLambdaHandler to LambdaHandler + +The `SimpleLambdaHandler` protocol provides a quick and easy way to implement a basic Lambda function. It only requires +an implementation of the `handle` function where the business logic of the Lambda function can be written. +`SimpleLambdaHandler` is perfectly sufficient for small use-cases as the user does not need to spend much time looking +into the library. + +However, `SimpleLambdaHandler` cannot be used when services such as a database client need to be initialized before the +Lambda runtime starts and then also gracefully shutdown prior to the runtime terminating. This is because the only way +to register termination logic is through the `LambdaInitializationContext` (containing a field +`terminator: LambdaTerminator`) which is created and used _internally_ within `LambdaRuntime` and never exposed through +`SimpleLambdaHandler`. For such use-cases, other handler protocols like `LambdaHandler` must be used. `LambdaHandler` +exposes a `context` argument of type `LambdaInitializationContext` through its initializer. Within the initializer, +required services can be initialized and their graceful shutdown logic can be registered with the +`context.terminator.register` function. + +Yet, `LambdaHandler` is quite cumbersome to use in such use-cases as users have to deviate from the established norms of +the Swift on Server ecosystem in order to cleanly manage the lifecycle of the services intended to be used. This is +because the convenient `swift-service-lifecycle` v2 library — which is commonly used for cleanly managing the lifecycles +of required services and widely supported by many libraries — cannot be used in a structured concurrency manner. + +#### Does not integrate well with swift-service-lifecycle in a structured concurrency manner + +The Lambda runtime can only be started using the **internal** `Lambda.run()` function. This function is called by the +`main()` function defined by the `LambdaHandler` protocol, preventing users from injecting initialized services into the +runtime _prior_ to it starting. As shown below, this forces users to use an **unstructured concurrency** approach and +manually initialize services, leading to the issue of the user then perhaps forgetting to gracefully shutdown the +initialized services: + +```swift +struct MyLambda: LambdaHandler { + let pgClient: PostgresClient + + init(context: AWSLambdaRuntimeCore.LambdaInitializationContext) async throws { + /// Instantiate service + let client = PostgresClient(configuration: ...) + + /// Unstructured concurrency to initialize the service + let pgTask = Task { + await client.run() + } + + /// Store the client in `self` so that it can be used in `handle(...)` + self.pgClient = client + + /// !!! Must remember to explicitly register termination logic for PostgresClient !!! + context.terminator.register( + name: "PostgreSQL Client", + handler: { eventLoop in + pgTask.cancel() + return eventLoop.makeFutureWithTask { + await pgTask.value + } + } + ) + } + + func handle(_ event: Event, context: LambdaContext) async throws -> Output { + /// Use the initialized service stored in `self.pgClient` + try await self.pgClient.query(...) + } +} +``` + +#### Verbose Codable support + +In the current API, there are extensions and Codable wrapper classes for decoding events and encoding computed responses +for _each_ different handler protocol and for both `String` and `JSON` formats. This has resulted in a lot of +boilerplate code which can very easily be made generic and simplified in v2. + +### New features + +#### Support response streaming + +In April 2023 +[AWS introduced support for response streaming](https://aws.amazon.com/blogs/compute/introducing-aws-lambda-response-streaming/) +in Lambda. The current API does not support streaming. For v2 we want to change this. + +#### Scheduling background work + +In May +[AWS described in a blog post that you can run background tasks in Lambda](https://aws.amazon.com/blogs/compute/running-code-after-returning-a-response-from-an-aws-lambda-function/) +until the runtime asks for more work from the control plane. We want to support this by adding new API that allows +background processing, even after the response has been returned. + +## Proposed Solution + +### async/await-first API + +Large parts of `Lambda`, `LambdaHandler`, and `LambdaRuntime` will be re-written to use async/await constructs in place +of the `EventLoop` family of interfaces. + +### Providing ownership of main() and support for swift-service-lifecycle + +- Instead of conforming to a handler protocol, users can now create a `LambdaRuntime` by passing in a handler closure. +- `LambdaRuntime` conforms to `ServiceLifecycle.Service` by implementing a `run()` method that contains initialization + and graceful shutdown logic. +- This allows the lifecycle of the `LambdaRuntime` to be managed with `swift-service-lifecycle` _alongside_ and in the + same way the lifecycles of the required services are managed, e.g. + `try await ServiceGroup(services: [postgresClient, ..., lambdaRuntime], ...).run()`. +- Dependencies can now be injected into `LambdaRuntime`. With `swift-service-lifecycle`, services will be initialized + together with `LambdaRuntime`. +- The required services can then be used within the handler in a structured concurrency manner. + `swift-service-lifecycle` takes care of listening for termination signals and terminating the services as well as the + `LambdaRuntime` in correct order. +- `LambdaTerminator` can now be eliminated because its role is replaced with `swift-service-lifecycle`. The termination + logic of the Lambda function will be implemented in the conforming `run()` function of `LambdaRuntime`. + +With this, the earlier code snippet can be replaced with something much easier to read, maintain, and debug: + +```swift +/// Instantiate services +let postgresClient = PostgresClient() + +/// Instantiate LambdaRuntime with a closure handler implementing the business logic of the Lambda function +let runtime = LambdaRuntime { (event: Input, context: LambdaContext) in + /// Use initialized service within the handler + try await postgresClient.query(...) +} + +/// Use ServiceLifecycle to manage the initialization and termination +/// of the services as well as the LambdaRuntime +let serviceGroup = ServiceGroup( + services: [postgresClient, runtime], + configuration: .init(gracefulShutdownSignals: [.sigterm]), + logger: logger +) +try await serviceGroup.run() +``` + +### Simplifying Codable support + +A detailed explanation is provided in the **Codable Support** section. In short, much of the boilerplate code defined +for each handler protocol in `Lambda+Codable` and `Lambda+String` will be replaced with a single `LambdaCodableAdapter` +struct. + +This adapter struct is generic over (1) any handler conforming to a new handler protocol +`LambdaWithBackgroundProcessingHandler`, (2) the user-specified input and output types, and (3) any decoder and encoder +conforming to protocols `LambdaEventDecoder` and `LambdaOutputDecoder`. The adapter will wrap the underlying handler +with encoding/decoding logic. + +## Detailed Solution + +Below are explanations for all types that we want to use in AWS Lambda Runtime v2. + +### LambdaResponseStreamWriter + +We will introduce a new `LambdaResponseStreamWriter` protocol. It is used in the new `StreamingLambdaHandler` (defined +below), which is the new base protocol for the `LambdaRuntime` (defined below as well). + +```swift +/// A writer object to write the Lambda response stream into +public protocol LambdaResponseStreamWriter { + /// Write a response part into the stream. The HTTP response is started lazily before the first call to `write(_:)`. + /// Bytes written to the writer are streamed continually. + func write(_ buffer: ByteBuffer) async throws + /// End the response stream and the underlying HTTP response. + func finish() async throws + /// Write a response part into the stream and end the response stream as well as the underlying HTTP response. + func writeAndFinish(_ buffer: ByteBuffer) async throws +} +``` + +If the user does not call `finish()`, the library will automatically finish the stream after the last `write`. +Appropriate errors will be thrown if `finish()` is called multiple times, or if `write`/`writeAndFinish` is called after +`finish()`. + +### LambdaContext + +`LambdaContext` will be largely unchanged, but the `eventLoop` property will be removed. The `allocator` property of +type `ByteBufferAllocator` will also be removed because (1), we generally want to reduce the number of SwiftNIO types +exposed in the API, and (2), `ByteBufferAllocator` does not optimize the allocation strategies. The common pattern +observed across many libraries is to re-use existing `ByteBuffer`s as much as possible. This is also what we do for the +`LambdaCodableAdapter` (explained in the **Codable Support** section) implementation. + +```swift +/// A context object passed as part of an invocation in LambdaHandler handle functions. +public struct LambdaContext: Sendable { + /// The request ID, which identifies the request that triggered the function invocation. + public var requestID: String { get } + + /// The AWS X-Ray tracing header. + public var traceID: String { get } + + /// The ARN of the Lambda function, version, or alias that's specified in the invocation. + public var invokedFunctionARN: String { get } + + /// The timestamp that the function times out. + public var deadline: DispatchWallTime { get } + + /// For invocations from the AWS Mobile SDK, data about the Amazon Cognito identity provider. + public var cognitoIdentity: String? { get } + + /// For invocations from the AWS Mobile SDK, data about the client application and device. + public var clientContext: String? { get } + + /// `Logger` to log with. + /// + /// - note: The `LogLevel` can be configured using the `LOG_LEVEL` environment variable. + public var logger: Logger { get } +} +``` + +### Handlers + +We introduce three handler protocols: `StreamingLambdaHandler`, `LambdaHandler`, and +`LambdaWithBackgroundProcessingHandler`. + +#### StreamingLambdaHandler + +The new `StreamingLambdaHandler` protocol is the base protocol to implement a Lambda function. Most users will not use +this protocol and instead use the `LambdaHandler` protocol defined below. + +```swift +/// The base StreamingLambdaHandler protocol +public protocol StreamingLambdaHandler { + /// The business logic of the Lambda function + /// - Parameters: + /// - event: The invocation's input data + /// - responseWriter: A ``LambdaResponseStreamWriter`` to write the invocation's response to. + /// If no response or error is written to the `responseWriter` it will + /// report an error to the invoker. + /// - context: The LambdaContext containing the invocation's metadata + /// - Throws: + /// How the thrown error will be handled by the runtime: + /// - An invocation error will be reported if the error is thrown before the first call to + /// ``LambdaResponseStreamWriter.write(_:)``. + /// - If the error is thrown after call(s) to ``LambdaResponseStreamWriter.write(_:)`` but before + /// a call to ``LambdaResponseStreamWriter.finish()``, the response stream will be closed and trailing + /// headers will be sent. + /// - If ``LambdaResponseStreamWriter.finish()`` has already been called before the error is thrown, the + /// error will be logged. + mutating func handle(_ event: ByteBuffer, responseWriter: some LambdaResponseStreamWriter, context: LambdaContext) async throws +} +``` + +Using this protocol requires the `handle` method to receive the incoming event as a `ByteBuffer` and return the output +as a `ByteBuffer` too. + +Through the `LambdaResponseStreamWriter`, which is passed as an argument in the `handle` function, the **response can be +streamed** by calling the `write(_:)` function of the `LambdaResponseStreamWriter` with partial data repeatedly before +finally closing the response stream by calling `finish()`. Users can also choose to return the entire output and not +stream the response by calling `writeAndFinish(_:)`. + +This protocol also allows for background tasks to be run after a result has been reported to the AWS Lambda control +plane, since the `handle(...)` function is free to implement any background work after the call to +`responseWriter.finish()`. + +The protocol is defined in a way that supports a broad range of use-cases. The handle method is marked as `mutating` to +allow handlers to be implemented with a `struct`. + +An implementation that sends the number 1 to 10 every 500ms could look like this: + +```swift +struct SendNumbersWithPause: StreamingLambdaHandler { + func handle( + _ event: ByteBuffer, + responseWriter: some LambdaResponseStreamWriter, + context: LambdaContext + ) async throws { + for i in 1...10 { + // Send partial data + responseWriter.write(ByteBuffer(string: #"\#(i)\n\r"#)) + // Perform some long asynchronous work + try await Task.sleep(for: .milliseconds(500)) + } + // All data has been sent. Close off the response stream. + responseWriter.finish() + } +} +``` + +#### LambdaHandler: + +This handler protocol will be the go-to choice for most use-cases because it is completely agnostic to any +encoding/decoding logic -- conforming objects simply have to implement the `handle` function where the input and return +types are Swift objects. + +Note that the `handle` function does not receive a `LambdaResponseStreamWriter` as an argument. Response streaming is +not viable for `LambdaHandler` because the output has to be encoded prior to it being sent, e.g. it is not possible to +encode a partial/incomplete JSON string. + +```swift +public protocol LambdaHandler { + /// Generic input type + /// The body of the request sent to Lambda will be decoded into this type for the handler to consume + associatedtype Event + /// Generic output type + /// This is the return type of the handle() function. + associatedtype Output + + /// The business logic of the Lambda function. Receives a generic input type and returns a generic output type. + /// Agnostic to encoding/decoding + mutating func handle(_ event: Event, context: LambdaContext) async throws -> Output +} +``` + +#### LambdaWithBackgroundProcessingHandler: + +This protocol is exactly like `LambdaHandler`, with the only difference being the added support for executing background +work after the result has been sent to the AWS Lambda control plane. + +This is achieved by not having a return type in the `handle` function. The output is instead written into a +`LambdaResponseWriter` that is passed in as an argument, meaning that the `handle` function is then free to implement +any background work after the result has been sent to the AWS Lambda control plane. + +`LambdaResponseWriter` has different semantics to the `LambdaResponseStreamWriter`. Where the `write(_:)` function of +`LambdaResponseStreamWriter` means writing into a response stream, the `write(_:)` function of `LambdaResponseWriter` +simply serves as a mechanism to return the output without explicitly returning from the `handle` function. + +```swift +public protocol LambdaResponseWriter { + associatedtype Output + + /// Sends the generic Output object (representing the computed result of the handler) + /// to the AWS Lambda response endpoint. + /// An error will be thrown if this function is called more than once. + func write(_: Output) async throws +} + +public protocol LambdaWithBackgroundProcessingHandler { + /// Generic input type + /// The body of the request sent to Lambda will be decoded into this type for the handler to consume + associatedtype Event + /// Generic output type + /// This is the type that the handle() function will send through the ``LambdaResponseWriter``. + associatedtype Output + + /// The business logic of the Lambda function. Receives a generic input type and returns a generic output type. + /// Agnostic to JSON encoding/decoding + func handle( + _ event: Event, + outputWriter: some LambdaResponseWriter, + context: LambdaContext + ) async throws +} +``` + +##### Example Usage: + +```swift +struct BackgroundProcessingHandler: LambdaWithBackgroundProcessingHandler { + struct Input: Decodable { + let message: String + } + + struct Greeting: Encodable { + let echoedMessage: String + } + + typealias Event = Input + typealias Output = Greeting + + func handle( + _ event: Event, + outputWriter: some LambdaResponseWriter, + context: LambdaContext + ) async throws { + // Return result to the Lambda control plane + try await outputWriter.write(result: Greeting(echoedMessage: event.messageToEcho)) + + // Perform some background work, e.g: + try await Task.sleep(for: .seconds(10)) + + // Exit the function. All asynchronous work has been executed before exiting the scope of this function. + // Follows structured concurrency principles. + return + } +} +``` + +#### Handler Adapters + +Since the `StreamingLambdaHandler` protocol is the base protocol the `LambdaRuntime` works with, there are adapters to +make both `LambdaHandler` and `LambdaWithBackgroundProcessingHandler` compatible with `StreamingLambdaHandler`. + +1. `LambdaHandlerAdapter` accepts a `LambdaHandler` and conforms it to `LambdaWithBackgroundProcessingHandler`. This is + achieved by taking the generic `Output` object returned from the `handle` function of `LambdaHandler` and passing it + to the `write(_:)` function of the `LambdaResponseWriter`. + +2. `LambdaCodableAdapter` accepts a `LambdaWithBackgroundProcessingHandler` and conforms it to `StreamingLambdaHandler`. + This is achieved by wrapping the `LambdaResponseWriter` with the `LambdaResponseStreamWriter` provided by + `StreamingLambdaHandler`. A call to the `write(_:)` function of `LambdaResponseWriter` is translated into a call to + the `writeAndFinish(_:)` function of `LambdaResponseStreamWriter`. + +Both `LambdaHandlerAdapter` and `LambdaCodableAdapter` are described in greater detail in the **Codable Support** +section. + +To summarize, `LambdaHandler` can be used with the `LambdaRuntime` by first going through `LambdaHandlerAdapter` and +then through `LambdaCodableAdapter`. `LambdaWithBackgroundHandler` just requires `LambdaCodableAdapter`. + +For the common JSON-in and JSON-out use-case, there is an extension on `LambdaRuntime` that abstracts away this wrapping +from the user. + +### LambdaRuntime + +`LambdaRuntime` is the class that communicates with the Lambda control plane as defined in +[Building a custom runtime for AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html) and +forward the invocations to the provided `StreamingLambdaHandler`. It will conform to `ServiceLifecycle.Service` to +provide support for `swift-service-lifecycle`. + +```swift +/// The LambdaRuntime object. This object communicates with the Lambda control plane +/// to fetch work and report errors. +public final class LambdaRuntime: ServiceLifecycle.Service, Sendable + where Handler: StreamingLambdaHandler +{ + + /// Create a LambdaRuntime by passing a handler, an eventLoop and a logger. + /// - Parameter handler: A ``StreamingLambdaHandler`` that will be invoked + /// - Parameter eventLoop: An ``EventLoop`` on which the LambdaRuntime will be + /// executed. Defaults to an EventLoop from + /// ``NIOSingletons.posixEventLoopGroup``. + /// - Parameter logger: A logger + public init( + handler: sending Handler, + eventLoop: EventLoop = Lambda.defaultEventLoop, + logger: Logger = Logger(label: "Lambda") + ) + + /// Create a LambdaRuntime by passing a ``StreamingLambdaHandler``. + public convenience init(handler: sending Handler) + + /// Starts the LambdaRuntime by connecting to the Lambda control plane to ask + /// for events to process. If the environment variable AWS_LAMBDA_RUNTIME_API is + /// set, the LambdaRuntime will connect to the Lambda control plane. Otherwise + /// it will start a mock server that can be used for testing at port 8080 + /// locally. + /// Cancel the task that runs this function to close the communication with + /// the Lambda control plane or close the local mock server. This function + /// only returns once cancelled. + public func run() async throws +} +``` + +The current API allows for a Lambda function to be tested locally through a mock server by requiring an environment +variable named `LOCAL_LAMBDA_SERVER_ENABLED` to be set to `true`. If this environment variable is not set, the program +immediately crashes as the user will not have the `AWS_LAMBDA_RUNTIME_API` environment variable on their local machine +(set automatically when deployed to AWS Lambda). However, making the user set the `LOCAL_LAMBDA_SERVER_ENABLED` +environment variable is an unnecessary step that can be avoided. In the v2 API, the `run()` function will automatically +start the mock server when the `AWS_LAMBDA_RUNTIME_API` environment variable cannot be found. + +### Lambda + +We also add an enum to store a static function and a property on. We put this on the static `Lambda` because +`LambdaRuntime` is generic and thus has bad ergonomics for static properties and functions. + +```swift +enum Lambda { + /// This returns the default EventLoop that a LambdaRuntime is scheduled on. + /// It uses `NIOSingletons.posixEventLoopGroup.next()` under the hood. + public static var defaultEventLoop: any EventLoop { get } + + /// Report a startup error to the Lambda Control Plane API + public static func reportStartupError(any Error) async +} +``` + +Since the library now provides ownership of the `main()` function and allows users to initialize services before the +`LambdaRuntime` is initialized, the library cannot implicitly report +[errors that occur during initialization to the dedicated endpoint AWS exposes](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-initerror) +like it currently does through the `initialize()` function of `LambdaRunner` which wraps the handler's `init(...)` and +handles any errors thrown by reporting it to the dedicated AWS endpoint. + +To retain support for initialization error reporting, the `Lambda.reportStartupError(any Error)` function gives users +the option to manually report initialization errors in their closure handler. Although this should ideally happen +implicitly like it currently does in v1, we believe this is a small compromise in comparison to the benefits gained in +now being able to cleanly manage the lifecycles of required services in a structured concurrency manner. + +> Use-case: +> +> Assume we want to load a secret for the Lambda function from a secret vault first. If this fails, we want to report +> the error to the control plane: +> +> ```swift +> let secretVault = SecretVault() +> +> do { +> /// !!! Error thrown: secret "foo" does not exist !!! +> let secret = try await secretVault.getSecret("foo") +> +> let runtime = LambdaRuntime { (event: Input, context: LambdaContext) in +> /// Lambda business logic +> } +> +> let serviceGroup = ServiceGroup( +> services: [postgresClient, runtime], +> configuration: .init(gracefulShutdownSignals: [.sigterm]), +> logger: logger +> ) +> try await serviceGroup.run() +> } catch { +> /// Report startup error straight away to the dedicated initialization error endpoint +> try await Lambda.reportStartupError(error) +> } +> ``` + +### Codable support + +The `LambdaHandler` and `LambdaWithBackgroundProcessingHandler` protocols abstract away encoding/decoding logic from the +conformers as they are generic over custom `Event` and `Output` types. We introduce two adapters `LambdaHandlerAdapter` +and `CodableLambdaAdapter` that implement the encoding/decoding logic and in turn allow the respective handlers to +conform to `StreamingLambdaHandler`. + +#### LambdaHandlerAdapter + +Any handler conforming to `LambdaHandler` can be conformed to `LambdaWithBackgroundProcessingHandler` through +`LambdaHandlerAdapter`. + +```swift +/// Wraps an underlying handler conforming to ``LambdaHandler`` +/// with ``LambdaWithBackgroundProcessingHandler``. +public struct LambdaHandlerAdapter< + Event: Decodable, + Output, + Handler: LambdaHandler +>: LambdaWithBackgroundProcessingHandler where Handler.Event == Event, Handler.Output == Output { + let handler: Handler + + /// Register the concrete handler. + public init(handler: Handler) + + /// 1. Call the `self.handler.handle(...)` with `event` and `context`. + /// 2. Pass the generic `Output` object returned from `self.handler.handle(...)` to `outputWriter.write(_:)` + public func handle(_ event: Event, outputWriter: some LambdaResponseWriter, context: LambdaContext) async throws +} +``` + +#### LambdaCodableAdapter + +`LambdaCodableAdapter` accepts any generic underlying handler conforming to `LambdaWithBackgroundProcessingHandler`. It +also accepts _any_ encoder and decoder object conforming to the `LambdaEventDecoder` and `LambdaOutputEncoder` +protocols: + +##### LambdaEventDecoder and LambdaOutputEncoder protocols + +```swift +public protocol LambdaEventDecoder { + /// Decode the ByteBuffer representing the received event into the generic type Event + /// the handler will receive + func decode(_ type: Event.Type, from buffer: ByteBuffer) throws -> Event +} + +public protocol LambdaOutputEncoder { + /// Encode the generic type Output the handler has produced into a ByteBuffer + func encode(_ value: Output, into buffer: inout ByteBuffer) throws +} +``` + +We provide conformances for Foundation's `JSONDecoder` to `LambdaEventDecoder` and `JSONEncoder` to +`LambdaOutputEncoder`. + +`LambdaCodableAdapter` implements its `handle()` method by: + +1. Decoding the `ByteBuffer` event into the generic `Event` type. +2. Wrapping the `LambdaResponseStreamWriter` with a concrete `LambdaResponseWriter` such that calls to + `LambdaResponseWriter`s `write(_:)` are mapped to `LambdaResponseStreamWriter`s `writeAndFinish(_:)`. + - Note that the argument to `LambdaResponseWriter`s `write(_:)` is a generic `Output` object whereas + `LambdaResponseStreamWriter`s `writeAndFinish(_:)` requires a `ByteBuffer`. + - Therefore, the concrete implementation of `LambdaResponseWriter` also accepts an encoder. Its `write(_:)` function + first encodes the generic `Output` object and then passes it to the underlying `LambdaResponseStreamWriter`. +3. Passing the generic `Event` instance, the concrete `LambdaResponseWriter`, as well as the `LambdaContext` to the + underlying handler's `handle()` method. + +`LambdaCodableAdapter` can implement encoding/decoding for _any_ handler conforming to +`LambdaWithBackgroundProcessingHandler` if `Event` is `Decodable` and the `Output` is `Encodable` or `Void`, meaning +that the encoding/decoding stubs do not need to be implemented by the user. + +```swift +/// Wraps an underlying handler conforming to `LambdaWithBackgroundProcessingHandler` +/// with encoding/decoding logic +public struct LambdaCodableAdapter< + Handler: LambdaWithBackgroundProcessingHandler, + Event: Decodable, + Output, + Decoder: LambdaEventDecoder, + Encoder: LambdaOutputEncoder +>: StreamingLambdaHandler where Handler.Output == Output, Handler.Event == Event { + + /// Register the concrete handler, encoder, and decoder. + public init( + handler: Handler, + encoder: Encoder, + decoder: Decoder + ) where Output: Encodable + + /// For handler with a void output -- the user doesn't specify an encoder. + public init( + handler: Handler, + decoder: Decoder + ) where Output == Void, Encoder == VoidEncoder + + /// 1. Decode the invocation event using `self.decoder` + /// 2. Create a concrete `LambdaResponseWriter` that maps calls to `write(_:)` with the `responseWriter`s `writeAndFinish(_:)` + /// 2. Call the underlying `self.handler.handle()` method with the decoded event data, the concrete `LambdaResponseWriter`, + /// and the `LambdaContext`. + public mutating func handle( + _ request: ByteBuffer, + responseWriter: some LambdaResponseStreamWriter, + context: LambdaContext + ) async throws +} +``` + +### Handler as a Closure + +To create a Lambda function using the current API, a user first has to create an object and conform it to one of the +handler protocols by implementing the initializer and the `handle(...)` function. Now that `LambdaRuntime` is public, +this verbosity can very easily be simplified. + +#### ClosureHandler + +This handler is generic over any `Event` type conforming to `Decodable` and any `Output` type conforming to `Encodable` +or `Void`. + +```swift +public struct ClosureHandler: LambdaHandler { + /// Initialize with a closure handler over generic Input and Output types + public init(body: @escaping (Event, LambdaContext) async throws -> Output) where Output: Encodable + /// Initialize with a closure handler over a generic Input type (Void Output). + public init(body: @escaping (Event, LambdaContext) async throws -> Void) where Output == Void + /// The business logic of the Lambda function. + public func handle(_ event: Event, context: LambdaContext) async throws -> Output +} +``` + +Given that `ClosureHandler` conforms to `LambdaHandler`: + +1. We can extend the `LambdaRuntime` initializer such that it accepts a closure as an argument. +2. Within the initializer, the closure handler is wrapped with `LambdaCodableAdapter`. + +```swift +extension LambdaRuntime { + /// Initialize a LambdaRuntime with a closure handler over generic Event and Output types. + /// This initializer bolts on encoding/decoding logic by wrapping the closure handler with + /// LambdaCodableAdapter. + public init( + body: @escaping (Event, LambdaContext) async throws -> Output + ) where Handler == LambdaCodableAdapter, Event, Output, JSONDecoder, JSONEncoder> + + /// Same as above but for handlers with a void output + public init( + body: @escaping (Event, LambdaContext) async throws -> Void + ) where Handler == LambdaCodableAdapter, Event, Void, JSONDecoder, VoidEncoder> +} +``` + +We can now significantly reduce the verbosity and leverage Swift's trailing closure syntax to cleanly create and run a +Lambda function, abstracting away the decoding and encoding logic from the user: + +```swift +/// The type the handler will use as input +struct Input: Decodable { + var message: String +} + +/// The type the handler will output +struct Greeting: Encodable { + var echoedMessage: String +} + +/// A simple Lambda function that echoes the input +let runtime = LambdaRuntime { (event: Input, context: LambdaContext) in + Greeting(echoedMessage: event.message) +} + +try await runtime.run() +``` + +We also add a `StreamingClosureHandler` conforming to `StreamingLambdaHandler` for use-cases where the user wants to +handle encoding/decoding themselves: + +```swift +public struct StreamingClosureHandler: StreamingLambdaHandler { + + public init( + body: @escaping sending (ByteBuffer, LambdaResponseStreamWriter, LambdaContext) async throws -> () + ) + + public func handle( + _ request: ByteBuffer, + responseWriter: LambdaResponseStreamWriter, + context: LambdaContext + ) async throws +} + +extension LambdaRuntime { + public init( + body: @escaping sending (ByteBuffer, LambdaResponseStreamWriter, LambdaContext) async throws -> () + ) +} +``` + +## Alternatives considered + +### [UInt8] instead of ByteBuffer + +We considered using `[UInt8]` instead of `ByteBuffer` in the base `LambdaHandler` API. We decided to use `ByteBuffer` +for two reasons. + +1. 99% of use-cases will use the JSON codable API and will not directly get in touch with ByteBuffer anyway. For those + users it does not matter if the base API uses `ByteBuffer` or `[UInt8]`. +2. The incoming and outgoing data must be in the `ByteBuffer` format anyway, as Lambda uses SwiftNIO under the hood and + SwiftNIO uses `ByteBuffer` in its APIs. By using `ByteBuffer` we can save a copies to and from `[UInt8]`. This will + reduce the invocation time for all users. +3. The base `LambdaHandler` API is most likely mainly being used by developers that want to integrate their web + framework with Lambda (examples: Vapor, Hummingbird, ...). Those developers will most likely prefer to get the data + in the `ByteBuffer` format anyway, as their lower level networking stack also depends on SwiftNIO. + +### Users create a LambdaResponse, that supports streaming instead of being passed a LambdaResponseStreamWriter + +Instead of passing the `LambdaResponseStreamWriter` in the invocation we considered a new type `LambdaResponse`, that +users must return in the `StreamingLambdaHandler`. + +Its API would look like this: + +```swift +/// A response returned from a ``LambdaHandler``. +/// The response can be empty, a single ByteBuffer or a response stream. +public struct LambdaResponse { + /// A writer to be used when creating a streamed response. + public struct Writer { + /// Writes data to the response stream + public func write(_ byteBuffer: ByteBuffer) async throws + /// Closes off the response stream + public func finish() async throws + /// Writes the `byteBuffer` to the response stream and subsequently closes the stream + public func writeAndFinish(_ byteBuffer: ByteBuffer) async throws + } + + /// Creates an empty lambda response + public init() + + /// Creates a LambdaResponse with a fixed ByteBuffer. + public init(_ byteBuffer: ByteBuffer) + + /// Creates a streamed lambda response. Use the ``Writer`` to send + /// response chunks on the stream. + public init(_ stream: @escaping sending (Writer) async throws -> ()) +} +``` + +The `StreamingLambdaHandler` would look like this: + +```swift +/// The base LambdaHandler protocol +public protocol StreamingLambdaHandler { + /// The business logic of the Lambda function + /// - Parameters: + /// - event: The invocation's input data + /// - context: The LambdaContext containing the invocation's metadata + /// - Returns: A LambdaResponse, that can be streamed + mutating func handle( + _ event: ByteBuffer, + context: LambdaContext + ) async throws -> LambdaResponse +} +``` + +There are pros and cons for the API that returns the `LambdaResponses` and there are pros and cons for the API that +receives a `LambdaResponseStreamWriter` as a parameter. + +Concerning following structured concurrency principles the approach that receives a `LambdaResponseStreamWriter` as a +parameter has benefits as the lifetime of the handle function is tied to the invocation runtime. The approach that +returns a `LambdaResponse` splits the invocation into two separate function calls. First the handle method is invoked, +second the `LambdaResponse` writer closure is invoked. This means that it is impossible to use Swift APIs that use +`with` style lifecycle management patterns from before creating the response until sending the full response stream off. +For example, users instrumenting their lambdas with Swift tracing likely can not use the `withSpan` API for the full +lifetime of the request, if they return a streamed response. + +However, if it comes to consistency with the larger Swift on server ecosystem, the API that returns a `LambdaResponse` +is likely the better choice. Hummingbird v2, OpenAPI and the new Swift gRPC v2 implementation all use this approach. +This might be due to the fact that writing middleware becomes easier, if a Response is explicitly returned. + +We decided to implement the approach in which a `LambdaResponseStreamWriter` is passed to the function, since the +approach in which a `LambdaResponse` is returned can trivially be built on top of it. This is not true vice versa. + +We welcome the discussion on this topic and are open to change our minds and API here. + +### Adding a function `addBackgroundTask(_ body: sending @escaping () async -> ())` in `LambdaContext` + +Initially we proposed an explicit `addBackgroundTask(_:)` function in `LambdaContext` that users could call from their +handler object to schedule a background task to be run after the result is reported to AWS. We received feedback that +this approach for supporting background tasks does not exhibit structured concurrency, as code could still be in +execution after leaving the scope of the `handle(...)` function. + +For handlers conforming to the `StreamingLambdaHandler`, `addBackgroundTask(_:)` was anyways unnecessary as background +work could be executed in a structured concurrency manner within the `handle(...)` function after the call to +`LambdaResponseStreamWriter.finish()`. + +For handlers conforming to the `LambdaHandler` protocol, we considered extending `LambdaHandler` with a +`performPostHandleWork(...)` function that will be called after the `handle` function by the library. Users wishing to +add background work can override this function in their `LambdaHandler` conforming object. + +```swift +public protocol LambdaHandler { + associatedtype Event + associatedtype Output + + func handle(_ event: Event, context: LambdaContext) async throws -> Output + + func performPostHandleWork(...) async throws -> Void +} + +extension LambdaHandler { + // User's can override this function if they wish to perform background work + // after returning a response from ``handle``. + func performPostHandleWork(...) async throws -> Void { + // nothing to do + } +} +``` + +Yet this poses difficulties when the user wishes to use any state created in the `handle(...)` function as part of the +background work. + +In general, the most common use-case for this library will be to implement simple Lambda functions that do not have +requirements for response streaming, nor to perform any background work after returning the output. To keep things easy +for the common use-case, and with Swift's principle of progressive disclosure of complexity in mind, we settled on three +handler protocols: + +1. `LambdaHandler`: Most common use-case. JSON-in, JSON-out. Does not support background work execution. An intuitive + `handle(event: Event, context: LambdaContext) -> Output` API that is simple to understand, i.e. users are not exposed + to the concept of sending their response through a writer. `LambdaHandler` can be very cleanly implemented and used + with `LambdaRuntime`, especially with `ClosureHandler`. +2. `LambdaWithBackgroundProcessingHandler`: If users wish to augment their `LambdaHandler` with the ability to run + background tasks, they can easily migrate. A user simply has to: + 1. Change the conformance to `LambdaWithBackgroundProcessingHandler`. + 2. Add an additional `outputWriter: some LambdaResponseWriter` argument to the `handle` function. + 3. Replace the `return ...` with `outputWriter.write(...)`. + 4. Implement any background work after `outputWriter.write(...)`. +3. `StreamingLambdaHandler`: This is the base handler protocol which is intended to be used directly only for advanced + use-cases. Users are provided the invocation event as a `ByteBuffer` and a `LambdaResponseStreamWriter` where the + computed result (as `ByteBuffer`) can either be streamed (with repeated calls to `write(_:)`) or sent all at once + (with a single call to `writeAndFinish(_:)`). After closing the `LambdaResponseStreamWriter`, any background work can + be implemented. + +### Making LambdaResponseStreamWriter and LambdaResponseWriter ~Copyable + +We initially proposed to make the `LambdaResponseStreamWriter` and `LambdaResponseWriter` protocols `~Copyable`, with +the functions that close the response having the `consuming` ownership keyword. This was so that the compiler could +enforce the restriction of not being able to interact with the writer after the response stream has closed. + +However, non-copyable types do not compose nicely and add complexity for users. Further, for the compiler to actually +enforce the `consuming` restrictions, user's have to explicitly mark the writer argument as `consuming` in the `handle` +function. + +Therefore, throwing appropriate errors to prevent abnormal interaction with the writers seems to be the simplest +approach. + +## A word about versioning + +We are aware that AWS Lambda Runtime has not reached a proper 1.0. We intend to keep the current implementation around +at 1.0-alpha. We don't want to change the current API without releasing a new major. We think there are lots of adopters +out there that depend on the API in v1. Because of this we intend to release the proposed API here as AWS Lambda Runtime +v2.