Skip to content

Commit 412a345

Browse files
sebsto0xTim
andauthored
[core] Add user-facing API for Streaming Lambda functions that receive JSON events (#532)
Add user-facing API for Streaming Lambda functions that receives JSON events ### Motivation: Streaming Lambda functions developed by developers had no choice but to implement a handler that receives incoming data as a `ByteBuffer`. While this is useful for low-level development, I assume most developers will want to receive a JSON event to trigger their streaming Lambda function. Going efficiently from a `ByteBuffer` to a Swift struct requires some code implemented in the `JSON+ByteBuffer.swift` file of the librray. We propose to further help developers by providing them with a new `handler()` function that directly receives their `Decodable` type. ### Modifications: This PR adds a public facing API (+ unit test + updated README) allowing developers to write a handler method accepting any `Decodable` struct as input. ```swift import AWSLambdaRuntime import NIOCore // Define your input event structure struct StreamingRequest: Decodable { let count: Int let message: String let delayMs: Int? } // Use the new streaming handler with JSON decoding let runtime = LambdaRuntime { (event: StreamingRequest, responseWriter, context: LambdaContext) in context.logger.info("Received request to send \(event.count) messages") // Stream the messages for i in 1...event.count { let response = "Message \(i)/\(event.count): \(event.message)\n" try await responseWriter.write(ByteBuffer(string: response)) // Optional delay between messages if let delay = event.delayMs, delay > 0 { try await Task.sleep(for: .milliseconds(delay)) } } // Finish the stream try await responseWriter.finish() // Optional: Execute background work after response is sent context.logger.info("Background work: processing completed") } try await runtime.run() ``` This interface provides: - **Type-safe JSON input**: Automatic decoding of JSON events into Swift structs - **Streaming responses**: Full control over when and how to stream data back to clients - **Background work support**: Ability to execute code after the response stream is finished - **Familiar API**: Uses the same closure-based pattern as regular Lambda handlers Because streaming Lambda functions can be invoked either directly through the API or through Lambda Function URL, this PR adds the decoding logic to support both types, shielding developers from working with Function URL requests and base64 encoding. We understand these choice will have an impact on the raw performance for event handling. Those advanced users that want to get the maximum might use the existing `handler(_ event: ByteBuffer, writer: LambaStreamingWriter)` function to implement their own custom decoding logic. This PR provides a balance between ease of use for 80% of the users vs ultimate performance, without closing the door for the 20% who need it. ### Result: Lambda function developers can now use arbitrary `Decodable` Swift struct or Lambda events to trigger their streaming functions. 🎉 --------- Co-authored-by: Tim Condon <[email protected]>
1 parent f1514b1 commit 412a345

File tree

13 files changed

+1236
-3
lines changed

13 files changed

+1236
-3
lines changed

.github/workflows/pull_request.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
# We pass the list of examples here, but we can't pass an array as argument
3737
# Instead, we pass a String with a valid JSON array.
3838
# The workaround is mentioned here https://github.com/orgs/community/discussions/11692
39-
examples: "[ 'APIGateway', 'APIGateway+LambdaAuthorizer', 'BackgroundTasks', 'HelloJSON', 'HelloWorld', 'ResourcesPackaging', 'S3EventNotifier', 'S3_AWSSDK', 'S3_Soto', 'Streaming', 'Testing', 'Tutorial' ]"
39+
examples: "[ 'APIGateway', 'APIGateway+LambdaAuthorizer', 'BackgroundTasks', 'HelloJSON', 'HelloWorld', 'ResourcesPackaging', 'S3EventNotifier', 'S3_AWSSDK', 'S3_Soto', 'Streaming', 'StreamingFromEvent', 'Testing', 'Tutorial' ]"
4040
archive_plugin_examples: "[ 'HelloWorld', 'ResourcesPackaging' ]"
4141
archive_plugin_enabled: true
4242

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ Package.resolved
1212
.vscode
1313
Makefile
1414
.devcontainer
15-
.amazonq
15+
.amazonq
16+
samconfig.toml

Examples/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ This directory contains example code for Lambda functions.
3434

3535
- **[S3_Soto](S3_Soto/README.md)**: a Lambda function that uses [Soto](https://github.com/soto-project/soto) to invoke an [Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html) API (requires [AWS SAM](https://aws.amazon.com/serverless/sam/)).
3636

37-
- **[Streaming]**: create a Lambda function exposed as an URL. The Lambda function streams its response over time. (requires [AWS SAM](https://aws.amazon.com/serverless/sam/)).
37+
- **[Streaming](Streaming/README.md)**: create a Lambda function exposed as an URL. The Lambda function streams its response over time. (requires [AWS SAM](https://aws.amazon.com/serverless/sam/)).
38+
39+
- **[StreamingFromEvent](StreamingFromEvent/README.md)**: a Lambda function that combines JSON input decoding with response streaming capabilities, demonstrating the new streaming codable interface (requires [AWS SAM](https://aws.amazon.com/serverless/sam/) or the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)).
3840

3941
- **[Testing](Testing/README.md)**: a test suite for Lambda functions.
4042

Examples/Streaming/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,63 @@ When done testing, you can delete the infrastructure with this command.
234234
```bash
235235
sam delete
236236
```
237+
238+
## Payload decoding
239+
240+
The content of the input `ByteBuffer` depends on how you invoke the function:
241+
242+
- when you use [`InvokeWithResponseStream` API](https://docs.aws.amazon.com/lambda/latest/api/API_InvokeWithResponseStream.html) to invoke the function, the function incoming payload is what you pass to the API. You can decode the `ByteBuffer` with a [`JSONDecoder.decode()`](https://developer.apple.com/documentation/foundation/jsondecoder) function call.
243+
- when you invoke the function through a [Lambda function URL](https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html), the incoming `ByteBuffer` contains a payload that gives developer access to the underlying HTTP call. The payload contains information about the HTTP verb used, the headers received, the authentication method and so on. The [AWS documentation contains the details](https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html) of the payload. The [Swift Lambda Event library](https://github.com/swift-server/swift-aws-lambda-events) contains a [`FunctionURL` type](https://github.com/swift-server/swift-aws-lambda-events/blob/main/Sources/AWSLambdaEvents/FunctionURL.swift) ready to use in your projects.
244+
245+
Here is an example of Lambda function URL payload:
246+
247+
```json
248+
{
249+
"version": "2.0",
250+
"routeKey": "$default",
251+
"rawPath": "/",
252+
"rawQueryString": "",
253+
"headers": {
254+
"x-amzn-tls-cipher-suite": "TLS_AES_128_GCM_SHA256",
255+
"x-amzn-tls-version": "TLSv1.3",
256+
"x-amzn-trace-id": "Root=1-68762f44-4f6a87d1639e7fc356aa6f96",
257+
"x-amz-date": "20250715T103651Z",
258+
"x-forwarded-proto": "https",
259+
"host": "zvnsvhpx7u5gn3l3euimg4jjou0jvbfe.lambda-url.us-east-1.on.aws",
260+
"x-forwarded-port": "443",
261+
"x-forwarded-for": "2a01:cb0c:6de:8300:a1be:8004:e31a:b9f",
262+
"accept": "*/*",
263+
"user-agent": "curl/8.7.1"
264+
},
265+
"requestContext": {
266+
"accountId": "0123456789",
267+
"apiId": "zvnsvhpx7u5gn3l3euimg4jjou0jvbfe",
268+
"authorizer": {
269+
"iam": {
270+
"accessKey": "AKIA....",
271+
"accountId": "0123456789",
272+
"callerId": "AIDA...",
273+
"cognitoIdentity": null,
274+
"principalOrgId": "o-rlrup7z3ao",
275+
"userArn": "arn:aws:iam::0123456789:user/sst",
276+
"userId": "AIDA..."
277+
}
278+
},
279+
"domainName": "zvnsvhpx7u5gn3l3euimg4jjou0jvbfe.lambda-url.us-east-1.on.aws",
280+
"domainPrefix": "zvnsvhpx7u5gn3l3euimg4jjou0jvbfe",
281+
"http": {
282+
"method": "GET",
283+
"path": "/",
284+
"protocol": "HTTP/1.1",
285+
"sourceIp": "2a01:...:b9f",
286+
"userAgent": "curl/8.7.1"
287+
},
288+
"requestId": "f942509a-283f-4c4f-94f8-0d4ccc4a00f8",
289+
"routeKey": "$default",
290+
"stage": "$default",
291+
"time": "15/Jul/2025:10:36:52 +0000",
292+
"timeEpoch": 1752575812081
293+
},
294+
"isBase64Encoded": false
295+
}
296+
```
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// swift-tools-version: 6.0
2+
3+
import PackageDescription
4+
5+
// needed for CI to test the local version of the library
6+
import struct Foundation.URL
7+
8+
let package = Package(
9+
name: "StreamingFromEvent",
10+
platforms: [.macOS(.v15)],
11+
dependencies: [
12+
// during CI, the dependency on local version of swift-aws-lambda-runtime is added dynamically below
13+
.package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main")
14+
],
15+
targets: [
16+
.executableTarget(
17+
name: "StreamingFromEvent",
18+
dependencies: [
19+
.product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime")
20+
]
21+
)
22+
]
23+
)
24+
25+
if let localDepsPath = Context.environment["LAMBDA_USE_LOCAL_DEPS"],
26+
localDepsPath != "",
27+
let v = try? URL(fileURLWithPath: localDepsPath).resourceValues(forKeys: [.isDirectoryKey]),
28+
v.isDirectory == true
29+
{
30+
// when we use the local runtime as deps, let's remove the dependency added above
31+
let indexToRemove = package.dependencies.firstIndex { dependency in
32+
if case .sourceControl(
33+
name: _,
34+
location: "https://github.com/swift-server/swift-aws-lambda-runtime.git",
35+
requirement: _
36+
) = dependency.kind {
37+
return true
38+
}
39+
return false
40+
}
41+
if let indexToRemove {
42+
package.dependencies.remove(at: indexToRemove)
43+
}
44+
45+
// then we add the dependency on LAMBDA_USE_LOCAL_DEPS' path (typically ../..)
46+
print("[INFO] Compiling against swift-aws-lambda-runtime located at \(localDepsPath)")
47+
package.dependencies += [
48+
.package(name: "swift-aws-lambda-runtime", path: localDepsPath)
49+
]
50+
}

0 commit comments

Comments
 (0)