Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -1702,7 +1702,12 @@ lazy val awsLambdaCore: ProjectMatrix = (projectMatrix in file("serverless/aws/l
.settings(
name := "tapir-aws-lambda-core"
)
.jvmPlatform(scalaVersions = scala2And3Versions, settings = commonJvmSettings)
.jvmPlatform(
scalaVersions = scala2And3Versions,
settings = commonJvmSettings ++ Seq(
libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.awsLambdaInterface
)
)
.jsPlatform(scalaVersions = scala2Versions, settings = commonJsSettings)
.dependsOn(serverCore, circeJson, tests % "test")

Expand Down
20 changes: 13 additions & 7 deletions doc/server/aws.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ For an overview of how this works in more detail, see [this blog post](https://b
## Runtime & Server interpreters

Tapir supports three of the AWS Lambda runtimes: custom runtime, Java, and NodeJS. Below you have a list of classes that can be used as an entry point
to your Lambda application depending on runtime of your choice. Each one of them uses server interpreter, which responsibility is to transform Tapir
endpoints with associated server logic to function like `AwsRequest => F[AwsResponse]` in case of custom and Java runtime,
or `AwsJsRequest => Future[AwsJsResponse]` in case of NodeJS runtime. Currently, two server interpreters are available, the first one is using
cats-effect (`AwsCatsEffectServerInterpreter`), and the other one is using Scala Future (`AwsFutureServerInterpreter`). Custom runtime, and Java
runtime are using only cats-effect interpreter, where NodeJS runtime can be used with both interpreters.
These are corresponding classes for each of the supported runtime:
to your Lambda application depending on runtime of your choice. Each one of them uses the server interpreter, whose responsibility is to transform Tapir
endpoints with associated server logic to a function like `AwsRequest => F[AwsResponse]` (or `AwsRequest => AwsResponse` for direct-style) in case of custom and Java runtime,
or `AwsJsRequest => Future[AwsJsResponse]` in case of NodeJS runtime. Currently, three server interpreters are available: `AwsCatsEffectServerInterpreter`
using cats-effect, `AwsFutureServerInterpreter` using Scala Future, and `AwsSyncServerInterpreter` using direct-style (no effect wrapper).
Custom runtime uses the cats-effect interpreter. Java runtime can use the cats-effect interpreter (`LambdaHandler`) or the direct-style interpreter
(`SyncLambdaHandler`). NodeJS runtime can be used with both Future and cats-effect interpreters.
These are the corresponding classes for each of the supported runtimes:

* The `AwsLambdaIORuntime` for custom runtime. Implement the Lambda loop of reading the next request, computing and sending the response
through [Lambda runtime API](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html).
Expand All @@ -26,11 +27,16 @@ These are corresponding classes for each of the supported runtime:
interface for handling requests, response flow inside Java runtime.
* The `AwsJsRouteHandler` for NodeJS runtime. The main benefit is the reduced deployment time. Initialization of JVM-based application (
with `sam local`) took ~11 seconds on average, while Node.js based one only ~2 seconds.
* The `SyncLambdaHandler` for Java runtime, a direct-style alternative to `LambdaHandler` that uses `Identity` instead of cats-effect.
Uses `AwsSyncServerInterpreter` and doesn't require any effect library.

To start using any of the above add the following dependency:
To start using any of the above add one of the following dependencies:

```scala
// for cats-effect (LambdaHandler, AwsLambdaIORuntime, AwsCatsEffectServerInterpreter)
"com.softwaremill.sttp.tapir" %% "tapir-aws-lambda" % "@VERSION@"
// for direct-style / Future (SyncLambdaHandler, AwsSyncServerInterpreter, AwsFutureServerInterpreter)
"com.softwaremill.sttp.tapir" %% "tapir-aws-lambda-core" % "@VERSION@"
```

## Deployment
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package sttp.tapir.serverless.aws.lambda

import sttp.monad.MonadError
import sttp.monad.IdentityMonad
import sttp.shared.Identity
import AwsSyncServerInterpreter._

abstract class AwsSyncServerInterpreter extends AwsServerInterpreter[Identity]

object AwsSyncServerInterpreter {

implicit val idMonadError: MonadError[Identity] = IdentityMonad

def apply(serverOptions: AwsServerOptions[Identity]): AwsSyncServerInterpreter = {
new AwsSyncServerInterpreter {
override def awsServerOptions: AwsServerOptions[Identity] = serverOptions
}
}

def apply(): AwsSyncServerInterpreter = {
new AwsSyncServerInterpreter {
override def awsServerOptions: AwsServerOptions[Identity] = AwsSyncServerOptions.default
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package sttp.tapir.serverless.aws.lambda

import sttp.shared.Identity
import sttp.tapir.server.interceptor.CustomiseInterceptors

object AwsSyncServerOptions {

/** Allows customising the interceptors used by the server interpreter. */
def customiseInterceptors: CustomiseInterceptors[Identity, AwsServerOptions[Identity]] =
CustomiseInterceptors(
createOptions =
(ci: CustomiseInterceptors[Identity, AwsServerOptions[Identity]]) => AwsServerOptions(encodeResponseBody = true, ci.interceptors)
)

def default: AwsServerOptions[Identity] = customiseInterceptors.options

def noEncoding: AwsServerOptions[Identity] =
default.copy(encodeResponseBody = false)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package sttp.tapir.serverless.aws.lambda

import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler}
import io.circe._
import io.circe.generic.auto._
import io.circe.parser.decode
import io.circe.syntax._
import sttp.shared.Identity
import sttp.tapir.server.ServerEndpoint

import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter}
import java.nio.charset.StandardCharsets

/** [[SyncLambdaHandler]] is a direct-style entry point for handling requests sent to AWS Lambda application which exposes Tapir endpoints.
*
* @tparam R
* AWS API Gateway request type [[AwsRequestV1]] or [[AwsRequest]].
* @param options
* Server options of type AwsServerOptions.
*/
abstract class SyncLambdaHandler[R: Decoder](options: AwsServerOptions[Identity]) extends RequestStreamHandler {

protected def getAllEndpoints: List[ServerEndpoint[Any, Identity]]

override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = {
val server: AwsSyncServerInterpreter = AwsSyncServerInterpreter(options)

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
val allBytes = input.readAllBytes()
val decoded = decode[R](new String(allBytes, StandardCharsets.UTF_8))
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
val response = decoded match {
case Left(e) => AwsResponse.badRequest(s"Invalid AWS request: ${e.getMessage}")
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
case Right(awsRequest) =>
awsRequest match {
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")
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")

Copilot uses AI. Check for mistakes.
}
}

val writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))
try {
writer.write(Printer.noSpaces.print(response.asJson))
} finally {
writer.flush()
writer.close()
}
}
}

object SyncLambdaHandler {

def apply[R: Decoder](
endpoints: List[ServerEndpoint[Any, Identity]],
serverOptions: AwsServerOptions[Identity]
): SyncLambdaHandler[R] =
new SyncLambdaHandler[R](serverOptions) {
override protected def getAllEndpoints: List[ServerEndpoint[Any, Identity]] = endpoints
}

def default[R: Decoder](endpoints: List[ServerEndpoint[Any, Identity]]): SyncLambdaHandler[R] =
apply(endpoints, AwsSyncServerOptions.noEncoding)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
apply(endpoints, AwsSyncServerOptions.noEncoding)
apply(endpoints, AwsSyncServerOptions.default)

Copilot uses AI. Check for mistakes.
}
Loading