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

Folders and files

NameName
Last commit message
Last commit date
Jun 3, 2024
May 6, 2024
Aug 1, 2024
Aug 1, 2024
Aug 1, 2024
Jun 3, 2024
Mar 6, 2019
Jun 13, 2024
Apr 1, 2019
Mar 5, 2021
Jun 17, 2024
Nov 25, 2018
May 6, 2024
May 6, 2024
May 6, 2024
Mar 27, 2019

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