Writing a Custom Lens with http4k

Posted by mattTea on Saturday, July 10, 2021

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…

  1. 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).

  1. The map() method, which takes as its argument, this BiDiMapping
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