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 function handleRequest 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:

  1. AWS API Gateway converts HTTP request to a JSON string that can be deserialized to a map.

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

  3. A Uber JAR/ZIP is required to deploy your application.

  4. Warm up is not guaranteed to work because the containers are maintained by AWS.

Happy hacking.


comments powered by Disqus