Skip to content

A REST request routing layer for AWS lambda handlers written in Kotlin

License

Notifications You must be signed in to change notification settings

moia-oss/lambda-kotlin-request-router

Repository files navigation

Build Status Coverage Status

lambda-kotlin-request-router

A REST request routing layer for AWS lambda handlers written in Kotlin.

Goal

We came up lambda-kotlin-request-router to reduce boilerplate code when implementing a REST API handlers on AWS Lambda.

The library addresses the following aspects:

  • serialization and deserialization
  • provide useful extensions and abstractions for API Gateway request and response types
  • writing REST handlers as functions
  • ease implementation of cross cutting concerns for handlers
  • ease (local) testing of REST handlers

Reference

Getting Started

To use the core module we need the following:

repositories {
    maven { url 'https://jitpack.io' }
}

dependencies {
    implementation 'io.moia.lambda-kotlin-request-router:router:0.9.7' 
}

Having this we can now go ahead and implement our first handler. We can implement a request handler as a simple function. Request and response body are deserialized and serialized for you.

import io.moia.router.Request
import io.moia.router.RequestHandler
import io.moia.router.ResponseEntity
import io.moia.router.Router.Companion.router

class MyRequestHandler : RequestHandler() {

    override val router = router {
        GET("/some") { r: Request<String> -> ResponseEntity.ok(MyResponse(r.body)) }
    }
}

Content Negotiation

The router DSL allows for configuration of the content types a handler

  • produces (according to the request's Accept header)
  • consumes (according to the request's Content-Type header)

The router itself carries a default for both values.

var defaultConsuming = setOf("application/json")
var defaultProducing = setOf("application/json")

These defaults can be overridden on the router level or on the handler level to specify the content types most of your handlers consume and produce.

router {
    defaultConsuming = setOf("application/json")
    defaultProducing = setOf("application/json")
}

Exceptions from this default can be configured on a handler level.

router {
    POST("/some") { r: Request<String> -> ResponseEntity.ok(MyResponse(r.body)) }
        .producing("application/json")
        .consuming("application/json")
}

Filters

Filters are a means to add cross-cutting concerns to your request handling logic outside a handler function. Multiple filters can be used by composing them.

override val router = router {
        filter = loggingFilter().then(mdcFilter())

        GET("/some", controller::get)
    }

    private fun loggingFilter() = Filter { next -> {
        request ->
            log.info("Handling request ${request.httpMethod} ${request.path}")
            next(request) }
    }

    private fun mdcFilter() = Filter { next -> {
        request ->
            MDC.put("requestId", request.requestContext?.requestId)
            next(request) }
    }
}

Permissions

Permission handling is a cross-cutting concern that can be handled outside the regular handler function. The routing DSL also supports expressing required permissions:

override val router = router {
    GET("/some", controller::get).requiringPermissions("A_PERMISSION", "A_SECOND_PERMISSION")
}

For the route above the RequestHandler checks if any of the listed permissions are found on a request.

Additionally we need to configure a strategy to extract permissions from a request on the RequestHandler. By default a RequestHandler is using the NoOpPermissionHandler which always decides that any required permissions are found. The JwtPermissionHandler can be used to extract permissions from a JWT token found in a header.

class TestRequestHandlerAuthorization : RequestHandler() {
    override val router = router {
       GET("/some", controller::get).requiringPermissions("A_PERMISSION")
    }

    override fun permissionHandlerSupplier(): (r: APIGatewayProxyRequestEvent) -> PermissionHandler =
        { JwtPermissionHandler(
            request = it,
            //the claim to use to extract the permissions - defaults to `scope`
            permissionsClaim = "permissions",
            //separator used to separate permissions in the claim - defaults to ` `
            permissionSeparator = ","
        ) }
}

Given the code above the token is extracted from the Authorization header. We can also choose to extract the token from a different header:

JwtPermissionHandler(
    accessor = JwtAccessor(
        request = it,
        authorizationHeaderName = "custom-auth")
)

⚠️ The implementation here assumes that JWT tokens are validated on the API Gateway. So we do no validation of the JWT token.

Protobuf support

The module router-protobuf helps to ease implementation of handlers that receive and return protobuf messages.

implementation 'io.moia.lambda-kotlin-request-router:router-protobuf:0.9.7'

A handler implementation that wants to take advantage of the protobuf support should inherit from ProtoEnabledRequestHandler.

class TestRequestHandler : ProtoEnabledRequestHandler() {

        override val router = router {
            defaultProducing = setOf("application/x-protobuf")
            defaultConsuming = setOf("application/x-protobuf")

            defaultContentType = "application/x-protobuf"

            GET("/some-proto") { _: Request<Unit> -> ResponseEntity.ok(Sample.newBuilder().setHello("Hello").build()) }
                .producing("application/x-protobuf", "application/json")
            POST("/some-proto") { r: Request<Sample> -> ResponseEntity.ok(r.body) }
            GET<Unit, Unit>("/some-error") { _: Request<Unit> -> throw ApiException("boom", "BOOM", 400) }
        }

        override fun createErrorBody(error: ApiError): Any =
            io.moia.router.proto.sample.SampleOuterClass.ApiError.newBuilder()
                .setMessage(error.message)
                .setCode(error.code)
                .build()

        override fun createUnprocessableEntityErrorBody(errors: List<UnprocessableEntityError>): Any =
            errors.map { error ->
                io.moia.router.proto.sample.SampleOuterClass.UnprocessableEntityError.newBuilder()
                    .setMessage(error.message)
                    .setCode(error.code)
                    .setPath(error.path)
                    .build()
            }
    }

Make sure you override createErrorBody and createUnprocessableEntityErrorBody to map error type to your proto error messages.

Open API validation support

The module router-openapi-request-validator can be used to validate an interaction against an OpenAPI specification. Internally we use the swagger-request-validator to achieve this.

This library validates:

  • if the resource used is documented in the OpenApi specification
  • if request and response can be successfully validated against the request and response schema
  • ...
testImplementation 'io.moia.lambda-kotlin-request-router:router-openapi-request-validator:0.9.7'
    val validator = OpenApiValidator("openapi.yml")

    @Test
    fun `should handle and validate request`() {
        val request = GET("/tests")
            .withHeaders(mapOf("Accept" to "application/json"))

        val response = testHandler.handleRequest(request, mockk())

        validator.assertValidRequest(request)
        validator.assertValidResponse(request, response)
        validator.assertValid(request, response)
    }

If you want to validate all the API interactions in your handler tests against the API specification you can use io.moia.router.openapi.ValidatingRequestRouterWrapper. This a wrapper around your RequestHandler which transparently validates request and response.

    private val validatingRequestRouter = ValidatingRequestRouterWrapper(TestRequestHandler(), "openapi.yml")
    
    @Test
    fun `should return response on successful validation`() {
        val response = validatingRequestRouter
            .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk())

        then(response.statusCode).isEqualTo(200)
    }