An important part of web applications is how they route incoming requests to appropriate request handlers. This post details how I implemented Pellet’s first routing system, with room to grow in the future, whilst staying consistent with Pellet’s wider design goals of being fast, concise, and correct. Like the last post, it includes lots of code samples to help you follow along 🧑‍💻.

Requirements #

As with the structured logging feature in the previous post, the first thing I wanted to do, before writing any code, was define some terms. The definitions I came up with also form requirements of the new routing system:

  • The user should be able to define a number of routes, where a single route consists of a method, a path, and a reference to a route handler
  • A route handler will, given a particular context, return a single route response
  • A route response consists of an HTTP message in response to a request, including a status code, some headers, and an HTTP entity
  • In the context of a web framework, it’s probably worth decomposing a route path in to its path components (separated by a /)
  • Routes will sometimes require variable elements, and there should be a type-safe way to define these, and extract them in handlers - for example, to let API callers pass an ID, as a UUID, in the route path
  • Variable sized variables are likely to be much harder to implement well, so only fixed-size elements should be considered - for example, consider mapping route paths to system file paths as out of scope for the first iteration

With these requirements and definitions in mind, I could start diving in to the code.

Implementation #

When I’m approaching a medium sized problem, I often like to think about the user-facing API first as a way of making sure the implementation ends up in a good place. When faced with complexity, a good way to break it down is to add constraints to make the problem smaller.

Routing #

With that in mind, the first thing to think about was the router - how the user would define routes, and how to quickly match incoming requests to an appropriate handler (called route resolution). I’ve been enjoying defining both a regular Java-style Builder, and augmenting it with a Kotlin Builder (using Kotlin’s DSL feature1), to give the user choice about style. So, the router building aspect looks something like this in code - I define a @DslMarker, a classic Builder, and a Kotlin DSL function:

@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class PelletBuilderDslTag

@PelletBuilderDslTag
object PelletBuilder {

    suspend fun httpRouter(
        lambda: suspend (@PelletBuilderDslTag RouterBuilder).() -> Unit
    ): PelletHTTPRouter {
        val builder = RouterBuilder()
        lambda(builder)
        return builder.build()
    }
}

@PelletBuilderDslTag
class RouterBuilder {

    private val router = PelletHTTPRouter()

    fun get(
        routePath: PelletHTTPRoutePath,
        handler: PelletHTTPRouteHandling
    ) {
        router.add(PelletHTTPRoute(HTTPMethod.Get, routePath, handler))
    }

    // ... other methods

    internal fun build(): PelletHTTPRouter {
        return router
    }
}

This code sample references one of the concepts defined above - a “route path”. For familiarity, Pellet will include both string and type-safe options for “simple” paths, but if you want to include variables, you’ll have to build the path using another concept, called “descriptors”.

A descriptor in Pellet is a way to map a named variable to a type-safe deserialiser (to turn a String in to a specific type like a UUID), and can be used to handle both query parameters and path parameters. Both can be defined as a “route variable descriptor” as follows, with an example of adding a safe UUID descriptor:

data class RouteVariableDescriptor<T : Any?>(
    val name: String,
    val deserialiser: (String) -> T
)

fun uuidDescriptor(
    name: String
): RouteVariableDescriptor<UUID> {
    return RouteVariableDescriptor(name) {
        UUID.fromString(it)
    }
}

We can use these descriptors to build route paths that are automatically split in to components, like so:

val idDescriptor = uuidDescriptor("id")

// path: /v1/{id:UUID}/hello
val helloIdPath = PelletHTTPRoutePath.Builder()
    .addComponents("/v1")
    .addVariable(idDescriptor)
    .addComponents("/hello")
    .build()

val router = httpRouter {
	get(helloIdPath) { // context ->
        // ...
    }
}

Which leaves us with the concern of reusing this descriptor, in a handler, to extract the path variable we expect. Doing so requires some sort of “context” to operate on inside the handler - a context should have information about the incoming request in it, like the route path, HTTP entity, query parameters and such. The basic code definition of a route context is:

data class PelletHTTPRouteContext(
    val rawMessage: HTTPRequestMessage,
    val entity: EntityContext?,
    internal val pathValueMap: Map<String, String>,
    internal val queryParameters: QueryParameters
) {

    data class EntityContext(
        val rawEntity: HTTPEntity.Content,
        val contentType: ContentType
    )

    // ...
}

Having such a context lets us define functions to extract query parameter and path parameter values - for example:

fun <T : Any> PelletHTTPRouteContext.pathParameter(
    descriptor: RouteVariableDescriptor<T>
): Result<T> {
    val rawValue = pathValueMap[descriptor.name]
        ?: return Result.failure(
            RuntimeException("no such path parameter found")
        )

    return runCatching {
        descriptor.deserialiser.invoke(rawValue)
    }
}

fun <T : Any> PelletHTTPRouteContext.firstQueryParameter(
    descriptor: RouteVariableDescriptor<T>
): Result<T> {
    val rawValue = queryParameters[descriptor.name]
        ?.firstOrNull()
        ?: return Result.failure(
            RuntimeException("no such query parameter found")
        )

    return runCatching {
        descriptor.deserialiser.invoke(rawValue)
    }
}

Note that these methods make extensive use of Kotlin’s Result type, so that users get either a success of type T or a failure with a cause of type Throwable. Java frameworks (and indeed Java libraries in general) have a tendency to throw errors and let some system up the chain deal with it, but I think it’s better to deal with errors when you have enough context to do something useful, like recover.

The router itself is pretty straightforward - it builds an internal list of routes, and when a request comes in, filters that list to routes that are an exact match for the request, and returns the first one.

The only slightly tricky aspect of routing relates to path variables. Given an incoming HTTP request, the exact match must take in to account whether a component of a path can be “variable” or not. Thankfully, we can get a lot of this for free at runtime by doing some extra work “up front” when we define routes themselves - by “componentising” the route paths when they’re defined. Path components can either be Plain or a Variable, so the route path class ends up looking like this:

data class PelletHTTPRoutePath(
    internal val components: List<Component>
) {

    companion object {

        fun parse(rawPath: String): PelletHTTPRoutePath {
            return Builder()
                .addComponents(rawPath)
                .build()
        }
    }

    sealed class Component {

        data class Plain(val string: String) : Component()
        data class Variable(val name: String) : Component()
    }

    public class Builder {

        private var components = mutableListOf<Component>()

        fun addComponents(string: String): Builder {
            components += string
                .split("/")
                .mapNotNull {
                    val trimmedString = it
                        .removePrefix("/")
                        .removeSuffix("/")
                    if (trimmedString.isEmpty()) {
                        return@mapNotNull null
                    }
                    Component.Plain(trimmedString)
                }
            return this
        }

        fun addVariable(
            variableName: String
        ): Builder {
            components += Component.Variable(variableName)
            return this
        }

        fun addVariable(
            descriptor: RouteVariableDescriptor<*>
        ): Builder {
            components += Component.Variable(descriptor.name)
            return this
        }

        fun build(): PelletHTTPRoutePath {
            return PelletHTTPRoutePath(
                components
            )
        }
    }
}

Doing the extra work up front to split the route path means that, if you also split incoming request paths in to components, matching routes becomes very cheap indeed. Parsing variables out is trivial because if the route matches, then the contents of the incoming path component, at a given index, form the contents of the variable in a defined route.

In the future this routing system could be further improved by constructing a tree structure at startup, to avoid iterating through a list of routes one-by-one to see if they match. However, my intuition is that in practice the routing will be plenty fast until you have loads of routes defined.

Route handlers #

Route handlers are where the user, given a context (discussed above), can reply to an incoming HTTP request message. In HTTP, one should send a single logical reply to a message. Note that I say logical reply, because a response could actually consist of several literal response sections - think of a chunked transfer, where you might want to send an HTTP status line and headers, then stream the contents of a large file whilst avoiding having it all in memory at once.

Replying is important enough that I felt it needed its own Builder to make things nice and easy for the user. I wanted to include methods to set status codes and response bodies, as well as helper methods that could affect multiple aspects of the response at the same time. For example, sending a JSON response implies a particular content type too, and “no content” responses should remove the Content-Type and Content-Length headers completely. With that in mind, the response class and builder ends up looking like this:

data class HTTPRouteResponse(
    val statusCode: Int,
    val headers: HTTPHeaders,
    val entity: HTTPEntity
) {

    class Builder {

        private var statusCode: Int = 0
        private val headers = HTTPHeaders()
        private var entity: HTTPEntity = HTTPEntity.NoContent

        fun statusCode(code: Int): Builder {
            statusCode = code
            return this
        }

        fun header(
            name: String,
            value: String
        ): Builder {
            headers.add(
                HTTPHeader(name, value)
            )
            return this
        }

        fun noContent(): Builder {
            statusCode = 204
            setNoContent()
            return this
        }

        // ... other methods

        fun entity(entity: HTTPEntity): Builder {
            this.entity = entity
            return this
        }

        fun entity(
            byteBuffer: ByteBuffer,
            contentType: ContentType
        ): Builder {
            this.entity = HTTPEntity.Content(PelletBuffer(byteBuffer))
            val contentTypeHeaderValue = ContentTypeSerialiser.serialise(contentType)
            this.headers[HTTPHeaderConstants.contentType] = contentTypeHeaderValue
            return this
        }

        fun entity(
            entity: String,
            contentType: ContentType
        ): Builder {
            val charset = contentType.charset() ?: Charsets.UTF_8
            val byteBuffer = charset.encode(entity)
            return this.entity(byteBuffer, contentType)
        }

        inline fun <reified T> jsonEntity(
            encoder: Json,
            value: T
        ): Builder {
            val encodedResponse = encoder.encodeToString(value)
            val contentType = ContentTypes.Application.JSON
            this.entity(encodedResponse, contentType)
            return this
        }

        fun build(): HTTPRouteResponse {
            val statusCode = this.statusCode

            val response = HTTPRouteResponse(
                statusCode = statusCode,
                headers = headers,
                entity = entity
            )
            return response
        }

        private fun setNoContent() {
            entity = HTTPEntity.NoContent
            this.headers -= HTTPHeaderConstants.contentType
            this.headers -= HTTPHeaderConstants.contentLength
        }
    }
}

That’s a lot of code samples so far! There’s one more important one, however, which is tying all of this together. The following sample defines a new server, with a single route at /v1/{id:UUID}/hello, that sends a friendly reply in a JSON response:

val idDescriptor = uuidDescriptor("id")

fun main() = runBlocking {
    val helloIdPath = PelletHTTPRoutePath.Builder()
        .addComponents("/v1")
        .addVariable(idDescriptor)
        .addComponents("/hello")
        .build()
    val pellet = pelletServer {
        httpConnector {
            endpoint = PelletConnector.Endpoint(
                hostname = "localhost",
                port = 8080
            )
            router = httpRouter {
                get(helloIdPath, ::handleHello)
            }
        }
    }
    pellet.start().join()
}

@Serializable
data class ResponseBody(
    val message: String
)

suspend fun handleHello(
    context: PelletHTTPRouteContext
): HTTPRouteResponse {
    val id = context.pathParameter(idDescriptor).getOrThrow()
    val responseBody = ResponseBody(message = "hello $id 👋")
    return HTTPRouteResponse.Builder()
        .statusCode(200)
        .jsonEntity(Json, responseBody)
        .build()
}

Sending a request looks like:

carrot 🥕 $ http localhost:8080/v1/06b39add-2b57-4d58-b084-40afeacab2e9/hello
HTTP/1.1 200 OK
Content-Length: 61
Content-Type: application/json

{
    "message": "hello 06b39add-2b57-4d58-b084-40afeacab2e9 👋"
}

In my opinion that’s a pretty nice looking demo - there’s a good balance of conciseness and correctness, and it’s really fast, which I’ll discuss next 🚀.

Benchmarking #

In previous posts I’ve discussed simple approaches to benchmarking, but I had a suspicion that I wasn’t quite getting everything I could out of Pellet. I decided to give TechEmpower’s “Framework Benchmarks”2 a try, as I knew it could handle sending a huge number of requests. As a bonus, I’ve submitted a pull request, and if it’s merged, Pellet will be automatically measured, and the results show up in the list of frameworks alongside others like Ktor.

I won’t go in to detail about adding a new benchmark to the framework, because their documentation was great, but Pellet serves around ~200k requests per second on my machine using the JSON benchmark, and ~800k using plaintext. At that point you’re likely starting to stress test the operating system’s network stack instead of the framework, and macOS isn’t especially well suited to these kinds of stress tests, so I think the JSON benchmark is more indicative of performance under real-world load.

Conclusion #

Pellet now has a solid routing system that I think meets the wider design goals of being fast, concise, and correct ✨. There’s also room for improvement as the framework develops and caters to more use cases, especially relating to the route matching system.

Builds are now published to Maven Central to make it easy to try the framework out, under the dev.pellet coordinate! There are instructions on setting things up using Gradle in the README.

Please star the project on GitHub if you like where it’s going (GitHub Repo stars), and share the post to social media - doing so helps more people discover the framework, and I’ve already had some really helpful feedback on Reddit. Thank you for reading 🙇!


Footnotes #

« Building Pellet – Structured Logging Using a Stream Deck with OBS 28 »