http4k and lenses
http4k
is a lightweight web framework written in and designed for use with Kotlin. One of the many features I love is the simplicity of marshalling and validating http requests using its Lens api. For anyone (like me) not from a functional programming background, here, a lens
is used to get a particular value from, or set a particular value onto, an http message, without the need for marshalling.
I picked up a requirement recently that gave me a great opportunity to dig into http4k
’s lens DSL a bit deeper…
An excuse to play
We have a GET endpoint in one of our services that builds a spreadsheet of a set of resources (this set has a single ID), and returns it in the response body
GET /resources/{id}
We wanted to extend this to return a different data format (json), but without creating a separate endpoint in order to do this.
http4k
provides typesafe contracts for http requests through lenses and Contract Routes
. The following example shows a lens providing type safety for a simple implementation of the above endpoint using a contract route…
fun getResourcesRoute(resourcesRetriever: ResourcesRetriever) =
"/resources" / Path.uuid().of("id") bindContract GET to { id ->
try {
val spreadsheet = resourcesRetriever(id)
Response(OK).body(spreadsheet.inputStream())
} catch (e: Exception) {
// ...
}
}
The line…
"/resources" / Path.uuid().of("id")
is using a UUID
lens on the Path
object of the route, and it’s providing UUID type safety for the id
parameter.
Type safety in this context meaning that as the id
parameter should be of type UUID
, the route handler will only be called if the path segment following /resources/
can be parsed as a UUID
.
The http4k-contract
module provides validation for these type-conversions on each call through simple marshalling, returning an HTTP 400 (BAD_REQUEST) response for invalid calls.
Options
To extend this endpoint to be response format specific, I wanted to add the .xlsx
suffix after the id
path parameter. However, at first look it didn’t appear possible to keep the provided type safety built in.
http4k
also provides a non-contract routing option that would allow a suffix to be added, but it would lose the option to use lenses, and therefore the type safety. The closest I got with this option was to add my own typesafe error handling (to catch and handle non-UUID path parameters)…
fun getResourcesRoute(resourcesRetriever: ResourcesRetriever) =
"/resources/{id}.xlsx" bind GET to { request ->
try {
val id = UUID.fromString(request.path("id"))
val spreadsheet = resourcesRetriever(id)
Response(OK).body(spreadsheet.inputStream())
} catch (e: Exception) {
// id must be a valid UUID
}
}
Solution
Not being particularly happy with losing some of the things I love about http4k
in the above, I was prompted to look at writing a custom Path lens, essentially stripping off the suffix and converting to a UUID.
Looking at the existing uuid
lens used in the first example above (repeated here…)
fun getResourcesRoute(resourcesRetriever: ResourcesRetriever) =
"/resources" / Path.uuid().of("id") bindContract GET to { id ->
try {
val spreadsheet = resourcesRetriever(id)
Response(OK).body(spreadsheet.inputStream())
} catch (e: Exception) {
// ...
}
}
the lens makes use of 2 methods in the http4k
library as follows…
Path.uuid()
which returns a bi-directional Path lens of type UUID
fun Path.uuid(): BiDiPathLensSpec<UUID> = map(StringBiDiMappings.uuid())
The StringBiDiMappings.uuid()
part here constructs a BiDiMapping(UUID::fromString, UUID::toString)
.
- The
map()
method, which takes as its argument, thisBiDiMapping
fun <IN, NEXT> BiDiPathLensSpec<IN>.map(mapping: BiDiMapping<IN, NEXT>) = map(mapping::invoke, mapping::invoke)
This second method, map()
, is the interesting bit, and creates a reusable transformation from an IN
type (the Path
), to a NEXT
type, or the type we want out to use in our code (in this case UUID
).
This is done through the BiDiMapping
argument passed into map()
having 2 invoke methods: asIn
(accepts our NEXT
type and uses it to call the (OUT) -> IN
mapper), and asOut
(accepts an IN
type and calls the (IN) -> OUT
mapper), creating this reusable, bi-directional transformation, or lens.
To create the custom Path lens needed for our case, I needed to create a copy of the map()
method, as this was private in the http4k
library…
private fun <IN, NEXT> BiDiPathLensSpec<IN>.map(mapping: BiDiMapping<IN, NEXT>) = map(mapping::invoke, mapping::invoke)
And a new extension method on Path
that would be our custom lens…
fun Path.uuidWithSuffix(suffix: String): BiDiPathLensSpec<UUID> =
map(
BiDiMapping(
asOut = { UUID.fromString(it.replace(Regex("$suffix\$"), "")) },
asIn = { "$it$suffix" },
)
)
The BiDiMapping
is instantiated here with the asIn
and asOut
functions using the ID and suffix string, and trimmed UUID respectively.
The endpoint then looked like this…
fun getResourcesRoute(resourcesRetriever: ResourcesRetriever) =
"/resources" / Path.uuidWithSuffix(".xlsx").of("id") bindContract GET to { id ->
try {
val spreadsheet = resourcesRetriever(id)
Response(OK).body(spreadsheet.inputStream())
} catch (e: Exception) {
// ...
}
}
Now an alternative endpoint could be created using the same route URL, but with a different file format suffix, generating a response in json for example…
GET /resources/{id}.xlsx // --> returns spreadsheet of resources
GET /resources/{id}.json // --> returns json string of the same resources
comments powered by Disqus