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