Kotlin API Server on AWS Lambda
This post demonstrates how to develop an API server written in Kotlin and deploy it to AWS Lambda.
The code is located on GitHub.
Tech Stack Choices
Kotlin
Kotlin is developed by JetBrains. It can be treated as a "better Java". It provides many nice features that let you write more compact code without performance penalty. Also it can be embedded into an existing Java program.
As a Lisp lover, I really favor the functional programming style Kotlin introduces. It is far better than Java Stream API in my opinion.
Maybe it is time to consider replacing Java with Kotlin code!
Gradle
Gradle is something in the middle of Maven and Ant. It supports 2 DSL: Groovy and Kotlin. It is not only a build tool, it is build tooling.
Please note that compared with Maven, Gradle is more complicated and may have some unexpected behavior if you are not familiar with it.
Selected Techniques
In this article I am using techniques of following versions:
-
Kotlin 1.3.50
-
Gradle 5.5.1
-
Serverless 1.52.2
System Architecture
Web Framework is Not Necessary
At first I thought Spring Boot was mandatory to develop an API server on AWS Lambda, so I created this repository. But later I found that I was wrong.
Since the API server is going to run behind AWS API Gateway, the Lambda function does not need to deal with routing or serialization. Instead, it can (and probably should) only care about incoming event. Otherwise every time the Lambda function is invoked, it takes seconds for Spring Boot to initialize.
The incoming HTTP requests are converted by API Gateway to a map that can be represented as Map<String, Any>
, which is the input of handler. The response of the handler is a String
or anything that can be serialized to a String
.
Building Basic Abstraction
Since there is no more Spring Boot that takes care of MVC layers, we need to roll our own. First let's abstract the request and response.
/**
* Const variables used globally.
*/
object Globals {
val objectMapper: ObjectMapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
object Environment {
val PATH: String = System.getenv("PATH")
}
}
data class ApiGatewayRequest(
/**
* The request body as String.
*/
var body: String? = null) {
/**
* Decode request body as JSON string.
*/
inline fun <reified T> decodeBody(): T? {
return try {
if (body != null) Globals.objectMapper.readValue(body, T::class.java) else null
} catch (e: JsonProcessingException) {
null
}
}
}
data class ApiGatewayResponse<T>(
val statusCode: Int = 200,
/**
* Any body that can be serialized to String.
*/
val body: T? = null) {
val bodyString: String = Globals.objectMapper.writeValueAsString(body)
}
Building Common Base Class
Each handler needs to implement RequestHandler
interface that defines a single handleRequest
function. Since you may want to add some common logic to all handlers, it is a good idea to create a common base class that will be inherited by all handlers.
object ApiGatewayRequestKeys {
const val BODY: String = "body"
}
object ApiGatewayResponseKeys {
const val STATUS_CODE = "statusCode"
const val BODY: String = "body"
}
/**
* A thin wrapper of [RequestHandler].
*/
abstract class ApiGatewayHandler<Out>
: RequestHandler<Map<String, Any>, Map<String, Any>> {
private val log = KotlinLogging.logger {}
override fun handleRequest(input: Map<String, Any>, context: Context): Map<String, Any> {
log.info { "Request: $input" }
log.info {
"""
Environment:
PATH=${Globals.Environment.PATH}
""".trimIndent()
}
val response: ApiGatewayResponse<Out> = try {
val requestBody = input[ApiGatewayRequestKeys.BODY] as String?
val request = ApiGatewayRequest(body = requestBody)
handleRequest(request)
} catch (e: BadRequestException) {
log.warn { "Bad request: ${e.message}" }
ApiGatewayResponse(statusCode = 400)
}
return response.toMap()
}
abstract fun handleRequest(request: ApiGatewayRequest): ApiGatewayResponse<Out>
}
/**
* Convert [ApiGatewayResponse] to corresponding [Map] that will be returned to the caller.
*/
fun <T> ApiGatewayResponse<T>.toMap(): Map<String, Any> {
return mapOf(
ApiGatewayResponseKeys.STATUS_CODE to this.statusCode,
ApiGatewayResponseKeys.BODY to this.bodyString)
}
The ApiGatewayHandler
class serves as an adapter between our basic abstractions and Lambda RequestHandler
:
-
It takes incoming map and convert it to
ApiGatewayRequest
. -
It calls its own abstract function
handleRequest
and converts the result to JSON string. The abstract functionhandleRequest
will be override by all handlers.
By doing so, it may be much easier to switch to another cloud service, or even go back to the Spring Boot framework.
Defining Sample Handler
Now we may define our handlers based on above abstraction blocks.
data class HelloRequest(
var name: String = ""
)
data class HelloResult(
var message: String = ""
)
/**
* Handlers /hello request.
*/
class HelloHandler : ApiGatewayHandler<HelloResult>() {
override fun handleRequest(request: ApiGatewayRequest): ApiGatewayResponse<HelloResult> {
val helloRequest = request.decodeBody<HelloRequest>() ?: throw BadRequestException()
return ApiGatewayResponse(body = HelloResult(message = "Hello, ${helloRequest.name}"))
}
}
You may note that the HelloHandler
only focuses on the real business logic. All the serialization work is done in the common class.
Writing Unit Tests
Unit tests are important to ensure that new functions will be break existing ones.
class HelloHandlerTest {
@Test
fun `test handleRequest`() {
val request = ApiGatewayRequest(body = "{\"name\": \"John\"}")
val handler = HelloHandler()
val response = handler.handleRequest(request)
assertNotNull(response.body)
assertEquals("{\"message\":\"Hello, John\"}", response.bodyString)
}
}
Using Gradle to Build It
AWS Lambda requires a JAR file, or a ZIP file. We may add the following snippet to the build.gradle.kts
file in order to build a fat JAR.
object Constants {
const val appName = "demo-api-server"
const val appVersion = "latest"
}
tasks.withType<Jar> {
archiveBaseName.set("demo-api-server")
from(configurations.compileClasspath.get().map {
if (it.isDirectory) it else zipTree(it)
})
}
Fat JAR, aka Uber JAR, is a JAR file that contains all the classes and their dependencies. Please note that you may want to remove unused dependencies to reduce the size of JAR file.
Using Serverless to Test and Deploy
Serverless
Serverless is very handy if you use AWS CodeBuild. It generates AWS CloudFormation template from a YAML file. You may install it globally using the following command:
~ $ npm install -g serverless
Afterwards serverless
or simply sls
can be used to invoke Serverless CLI.
Writing Serverless YAML
The official documentation of Serverless contains very detailed information about how to write its YAML file. Our demo-api-server
project may looks like this:
plugins:
# Used for warming up JVM container.
- serverless-plugin-warmup
custom:
params:
product: "demo-api-server"
warmup:
timeout: 20
events:
# Run every 5 minutes from 00:00 to 14:00 UTC, Monday to Friday.
- schedule: "cron(0/5 0-14 ? * MON-FRI *)"
concurrency: 2
prewarm: true
service: ${self:custom.params.product}
provider:
name: aws
runtime: java8
timeout: 60
memorySize: 128
functions:
hello:
handler: com.sheepduke.api.server.hello.HelloHandler::handleRequest
package:
artifact: build/libs/demo-api-server-1.0.jar
events:
- http:
path: ${self:custom.params.product}/hello
method: post
warmup:
enabled: true
Testing Lambda Function Locally
Serverless comes with a handy function to test Lambda functions locally:
~ $ sls invoke local --docker --function hello --data '{"body":"{\"name\":\"John\"}"}'
The command above invokes hello
function defined in the YAML file.
Please note that the string after --data
is passed to the function, so it must follow the format1 of API Gateway input event.
Now you should have seen the following output on your screen:
Serverless: WarmUp: setting 1 lambdas to be warm
Serverless: WarmUp: api-server-dev-hello
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Building Docker image...
START RequestId: 1ba968e6-f425-449d-b246-bdab04174ac1 Version: $LATEST
09:48:03.937 [main] INFO com.sheepduke.api.server.common.ApiGatewayHandler - Request: {body={"name":"John"}}
09:48:03.962 [main] INFO com.sheepduke.api.server.common.ApiGatewayHandler - Environment:
PATH=/usr/local/bin:/usr/bin/:/bin:/opt/bin
END RequestId: 1ba968e6-f425-449d-b246-bdab04174ac1
REPORT RequestId: 1ba968e6-f425-449d-b246-bdab04174ac1 Duration: 522.24 ms Billed Duration: 600 ms Memory Size: 1536 MB Max Memory Used: 51 MB
{"statusCode":200,"body":"{\"message\":\"Hello, John\"}"}
Optimization
Warm Up to Reduce Code Start Time
In the last post I mentioned that Java applications have long cold start time. To warm it up, we can apply serverless-plugin-warmup
plugin to Serverless. From my experiments, 5 minutes is a reasonable value for Lambda functions that are not in any VPC.
Conclusion
Some conclusions here:
-
AWS API Gateway converts HTTP request to a JSON string that can be deserialized to a map.
-
AWS API Gateway wants a response whose body is a string. If you are returning JSON, please note that the body is a JSON string. Do not put object in it, otherwise API Gateway will not be able to recognize it.
-
A Uber JAR/ZIP is required to deploy your application.
-
Warm up is not guaranteed to work because the containers are maintained by AWS.
Happy hacking.