Add direct-style AwsSyncServerInterpreter for AWS Lambda#5149
Add direct-style AwsSyncServerInterpreter for AWS Lambda#5149
Conversation
Adds a direct-style (Identity monad) AWS Lambda interpreter alongside the existing Future and cats-effect interpreters in lambda-core. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
cc @baldram |
There was a problem hiding this comment.
Pull request overview
Adds a new direct-style AWS Lambda server interpreter for Tapir’s AWS serverless integration, enabling JVM Lambda usage without an effect library by using Identity.
Changes:
- Introduces
AwsSyncServerInterpreterandAwsSyncServerOptions(Identity/direct-style). - Adds
SyncLambdaHandler(JVMRequestStreamHandler) to run Tapir endpoints in AWS Lambda without an effect runtime. - Updates AWS serverless docs and adds a JVM-only dependency needed for the new handler.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| serverless/aws/lambda-core/src/main/scalajvm/sttp/tapir/serverless/aws/lambda/SyncLambdaHandler.scala | New JVM direct-style Lambda handler that decodes requests and renders AwsResponse. |
| serverless/aws/lambda-core/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsSyncServerOptions.scala | Adds Identity-based server options builder (interceptors + encoding flag). |
| serverless/aws/lambda-core/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsSyncServerInterpreter.scala | Adds Identity-based interpreter wiring + implicit MonadError[Identity]. |
| doc/server/aws.md | Documents the new direct-style interpreter/handler and dependency selection. |
| build.sbt | Adds JVM-only AWS Lambda runtime interface client dependency to tapir-aws-lambda-core. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { | ||
| val server: AwsSyncServerInterpreter = AwsSyncServerInterpreter(options) | ||
|
|
There was a problem hiding this comment.
handleRequest largely duplicates the request decoding / V1->V2 mapping / response writing logic that already exists in LambdaHandler (cats-effect) and ZioLambdaHandler. With multiple copies, behavior can easily drift over time (e.g., error handling, encoding defaults). Consider extracting the shared JSON decode + request normalization + response rendering into a small helper in lambda-core that the various handlers can reuse.
| } | ||
|
|
||
| def default[R: Decoder](endpoints: List[ServerEndpoint[Any, Identity]]): SyncLambdaHandler[R] = | ||
| apply(endpoints, AwsSyncServerOptions.noEncoding) |
There was a problem hiding this comment.
SyncLambdaHandler.default currently uses AwsSyncServerOptions.noEncoding, which makes the method name misleading (it doesn't return the actual AwsSyncServerOptions.default). Consider either switching to AwsSyncServerOptions.default here, or renaming the method to reflect that it disables encoding (e.g., noEncoding).
| apply(endpoints, AwsSyncServerOptions.noEncoding) | |
| apply(endpoints, AwsSyncServerOptions.default) |
| val allBytes = input.readAllBytes() | ||
| val decoded = decode[R](new String(allBytes, StandardCharsets.UTF_8)) |
There was a problem hiding this comment.
InputStream.readAllBytes() is a Java 9+ API. This project still targets JVM 1.8 for Scala 2 (see commonJvmSettings), so calling this can fail at runtime on Java 8 with NoSuchMethodError. Consider replacing this with a Java-8-compatible read loop (e.g., via ByteArrayOutputStream) so the handler remains usable on Java 8 runtimes.
| override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { | ||
| val server: AwsSyncServerInterpreter = AwsSyncServerInterpreter(options) | ||
|
|
||
| val allBytes = input.readAllBytes() | ||
| val decoded = decode[R](new String(allBytes, StandardCharsets.UTF_8)) | ||
| val response = decoded match { | ||
| case Left(e) => AwsResponse.badRequest(s"Invalid AWS request: ${e.getMessage}") |
There was a problem hiding this comment.
This introduces new request/response handling logic in handleRequest (decoding the event, V1->V2 mapping, and writing the JSON response), but there are no tests covering the new SyncLambdaHandler behavior. Consider adding a small JVM unit test that feeds an InputStream with a sample AwsRequest/AwsRequestV1 JSON and asserts on the serialized AwsResponse written to the OutputStream (including the invalid-JSON -> 400 path).
Use fully qualified sttp.shared.Identity to avoid shadowing by case class Identity in the same package. Fix SyncLambdaHandler.default to use AwsSyncServerOptions.default. Add SyncLambdaApiExample. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 21 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { | ||
| val server: AwsSyncServerInterpreter = AwsSyncServerInterpreter(options) | ||
|
|
||
| val allBytes = input.readAllBytes() | ||
| val decoded = decode[R](new String(allBytes, StandardCharsets.UTF_8)) | ||
| val response = decoded match { | ||
| case Left(e) => AwsResponse.badRequest(s"Invalid AWS request: ${e.getMessage}") | ||
| case Right(awsRequest) => | ||
| awsRequest match { | ||
| case r: AwsRequestV1 => server.toRoute(getAllEndpoints)(r.toV2) | ||
| case r: AwsRequest => server.toRoute(getAllEndpoints)(r) |
There was a problem hiding this comment.
server.toRoute(getAllEndpoints) is computed inside request processing (and twice, once per case). toRoute builds a ServerInterpreter, so this adds avoidable overhead on every Lambda invocation. Consider computing the route once (e.g., a lazy val route = AwsSyncServerInterpreter(options).toRoute(getAllEndpoints)) and reusing it for both request types, converting AwsRequestV1 to V2 before calling the route.
| override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { | |
| val server: AwsSyncServerInterpreter = AwsSyncServerInterpreter(options) | |
| val allBytes = input.readAllBytes() | |
| val decoded = decode[R](new String(allBytes, StandardCharsets.UTF_8)) | |
| val response = decoded match { | |
| case Left(e) => AwsResponse.badRequest(s"Invalid AWS request: ${e.getMessage}") | |
| case Right(awsRequest) => | |
| awsRequest match { | |
| case r: AwsRequestV1 => server.toRoute(getAllEndpoints)(r.toV2) | |
| case r: AwsRequest => server.toRoute(getAllEndpoints)(r) | |
| private lazy val server: AwsSyncServerInterpreter = AwsSyncServerInterpreter(options) | |
| private lazy val route: AwsRequest => AwsResponse = server.toRoute(getAllEndpoints) | |
| override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { | |
| val allBytes = input.readAllBytes() | |
| val decoded = decode[R](new String(allBytes, StandardCharsets.UTF_8)) | |
| val response = decoded match { | |
| case Left(e) => AwsResponse.badRequest(s"Invalid AWS request: ${e.getMessage}") | |
| case Right(awsRequest) => | |
| awsRequest match { | |
| case r: AwsRequestV1 => route(r.toV2) | |
| case r: AwsRequest => route(r) |
| case r: AwsRequestV1 => server.toRoute(getAllEndpoints)(r.toV2) | ||
| case r: AwsRequest => server.toRoute(getAllEndpoints)(r) | ||
| case r => | ||
| throw new IllegalArgumentException(s"Request of type ${r.getClass.getCanonicalName} is not supported") |
There was a problem hiding this comment.
Throwing an IllegalArgumentException here will fail the whole Lambda invocation (often surfacing as a 502 from API Gateway) and can be hard to diagnose for callers. Prefer returning an AwsResponse.badRequest (or other 4xx) with a clear message, consistent with the JSON decode failure handling above.
| throw new IllegalArgumentException(s"Request of type ${r.getClass.getCanonicalName} is not supported") | |
| AwsResponse.badRequest(s"Request of type ${r.getClass.getCanonicalName} is not supported") |
| "name": "tapir-sandbox", | ||
| "dockerComposeFile": "compose-all.yml", | ||
| "service": "agent", | ||
| "workspaceFolder": "/workspaces/tapir-sandbox", |
There was a problem hiding this comment.
The PR title/description focus on AWS Lambda interpreters, but this change switches the devcontainer to a new multi-container “sandcat” setup and adds a large number of new scripts/configs under .devcontainer/. If these dev-environment changes are intentional, they should be called out explicitly in the PR description; otherwise, consider splitting them into a separate PR to keep the Lambda interpreter change reviewable.
| private-key <(echo "$client_private_key") \ | ||
| fwmark 51820 \ | ||
| peer "$server_public_key" \ | ||
| endpoint mitmproxy:51820 \ |
There was a problem hiding this comment.
The WireGuard peer endpoint is configured using the hostname mitmproxy:51820, but earlier the script resolves mitmproxy_ip specifically to avoid relying on DNS after routing changes. Consider setting the endpoint to the resolved IP (or otherwise ensuring DNS will still work here).
| endpoint mitmproxy:51820 \ | |
| endpoint "$mitmproxy_ip":51820 \ |
| image: ghcr.io/virtuslab/sandcat-mitmproxy-op:latest | ||
| command: mitmweb --mode wireguard --web-host 0.0.0.0 --set web_password=mitmproxy -s /scripts/mitmproxy_addon.py | ||
| ports: | ||
| - "8081" # mitmweb UI; host port assigned dynamically to avoid conflicts |
There was a problem hiding this comment.
In Docker Compose, the short port syntax "8081" maps host port 8081 to container port 8081 (equivalent to 8081:8081); it does not assign a random host port. Either update the comment or use a syntax that requests an ephemeral published port if avoiding conflicts is the goal.
| - "8081" # mitmweb UI; host port assigned dynamically to avoid conflicts | |
| - target: 8081 | |
| published: 0 | |
| protocol: tcp | |
| mode: host # mitmweb UI; host port assigned dynamically to avoid conflicts |
| "anthropic.claude-code", | ||
| "github.vscode-pull-request-github", | ||
| "redhat.java", | ||
| "scalameta.metals", |
There was a problem hiding this comment.
devcontainer.json contains a trailing comma after the last entry in the extensions array. Unless this file is guaranteed to be parsed as JSONC everywhere, this makes it invalid JSON and can break tooling that expects strict JSON. Removing trailing commas (and keeping JSONC-specific features only when needed) will improve compatibility.
| "scalameta.metals", | |
| "scalameta.metals" |
| # Resolve the mitmproxy endpoint IP before setting up the tunnel, since DNS | ||
| # won't be available through the normal path after routing is configured. | ||
| mitmproxy_ip=$(getent hosts mitmproxy | awk '{print $1}') | ||
|
|
There was a problem hiding this comment.
mitmproxy_ip is resolved here with a comment explaining DNS may not be available later, but it is never used (the WireGuard peer endpoint is still set to mitmproxy:51820). Either use the resolved IP in the wg set command or remove the resolution/comment to avoid misleading future maintainers.
The request decoding, V1→V2 normalization, and response writing logic was duplicated across SyncLambdaHandler, LambdaHandler, and ZioLambdaHandler. Extract it into a shared AwsLambdaCodec helper in lambda-core to prevent behavioral drift between the handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move interpreter + toRoute call to a lazy val so the ServerInterpreter is built once and reused across Lambda invocations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
AwsSyncServerInterpreter,AwsSyncServerOptions, andSyncLambdaHandler— a direct-style (Identity monad) AWS Lambda interpreter that requires no effect librarylambda-corealongside the existingAwsFutureServerInterpreter, withSyncLambdaHandlerin JVM-specific sourcesaws-lambda-java-runtime-interface-clientas a JVM-specific dependency onlambda-coreTest plan
awsLambdaCore3 / compile)awsLambdaCore / compile)🤖 Generated with Claude Code