In the previous post we have built up a mental model of the routing DSL. We’ve got an intuition for what routes and directives are. This time we’ll deepen this insight through learning how to build our own routes and directives.
I want to start by examining an excerpt from Structure and Interpretation of Computer Programs (SICP) to frame this exploration.
A powerful programming language is more than just a means for instructing a computer to perform tasks. The language also serves as a framework within which we organize our ideas about processes. Thus, when we describe a language, we should pay particular attention to the means that the language provides for combining simple ideas to form more complex ideas. Every powerful language has three mechanisms for accomplishing this:
- primitive expressions, which represent the simplest entities the language is concerned with,
- means of combination, by which compound elements are built from simpler ones, and
- means of abstraction, by which compound elements can be named and manipulated as units.
Even though the language in question is an embedded DSL, the ideas still apply. Let’s inspect each of these aspects in a little more depth before bringing it all together.
Abstraction
The ability to name and refer to compound elements is provided in the form of val
and def
from our host language.
The Hello World route from our first example in the previous post is a good example.
Here route
can be used to refer to the combination of a get
Directive combined with a complete
directive.
Primitives
From here on we’ll have to look at routes and directives separately.
Since directives are the building blocks they come with some more complexity so we’ll start with the routes.
ok at both.
The primitive routes are built-in and all inherit from StandardRoute
which means they are also directives.
The standard routes can conceptually be divided into 3 categories.
There is the happy path; this branch of the routing tree is able to process the request and sends a 3xx through a response
or uses complete
for any other response.
Then there is reject
to express that a request should not be handled by this branch and lastly there is failWith
which bubbles up an exception to be dealt with by a handleExceptions
directive.
Each branch will end in one of these routes after a combination of the following directives.
The first directive to look at is pass
which simply passes the request downstream.
It bears some resemblance to identity
in that it does not manipulate it’s input and placing it in a chain somewhere does not change the effect of that chain.
Here are some equivalent routes to illustrate.
get {
complete("I accept all GET requests")
}
pass {
get {
complete("I accept all GET requests")
}
}
get {
pass {
complete("I accept all GET requests")
}
}
You might wonder, if it doesn’t do anything then what is the point? This will soon be clarified.
Next there is provide
through which we can inject values into a directive.
It is often used to bring values into scope when making custom directives.
To show how it is used, a simplified (and completely unfit for production) version of the onComplete
directive which let’s us handle the outcome of the computation.
def simplifiedOnComplete[T](future: Future[T])(timeout: FiniteDuration): Directive1[T] = {
Try(Await.result(future, Duration.Inf)) match {
case Success(result) => provide(result)
case Failure(error) => failWith(error)
}
}
This directive behaves a similar to pure
in the Applicative
typeclass, lifting a value into a directive.
Neither of these primitives have had anything to do with HTTP requests, that’s what extract
is for.
It takes a function f: RequestContext => T
and bring the resulting value into scope.
Here is another simplified version of a built-in directive which rejects all but GET
requests.
val simplifiedGet: Directive0 =
extract(_.request.method) match {
case GET => pass
case other => reject()
}
In the directive we created here, depending on the method, the request might get rejected.
But on the happy path there is no transformation, that’s where pass
comes in.
Composition
Again, we’ll look at the composition into routes first, that can be from multiple routes or from a directive and a rouet and we’ll end on the composition of directives.
In the first post we described a route as a tree, the combinator that introduces branching is ~
.
It makes two routes into one, or to to stay in the metaphore of the tree, the ~
route node has two child routes.
If the first rejects the second branch gets offered the request.
If the second also rejects the combined route does too.
The most common way to prepend directives to a route is by applying a route to the directive.
The apply
method is added to the directive through implicit conversion.
In the case of a Directive0
, a directive that does not bring anything in scope such as pass
and get
, it takes a route and returns a new route with the directive prepended to the input.
For the other directives that do bring a value in scope such as extract
, provide
and our simplifiedOnComplete
, Directive1[T]
s, takes a function T => Route
.
Again, the directive is prepended but it brings a value T
into scope, making it possible to let the request handling depend on said value.
We have seen this before, but now that we have a better understanding of it, let’s look at the example that we we’ll build on for the last bit.
Pay attention to the function used in the apply
of parameter('id)
, the outer directive that extracts the query parameter id
.
The id is extracted from the request and used to find the correct item.
def fetchItem(itemId: String): Future[String]
def showItem(itemId: String): Route =
onComplete(fetchItem(itemId)) {
case Success(thing) => complete(thing)
case Failure(error) => failWith(error)
}
val route: Route = parameter('id) { itemId =>
showItem(fetchItem(itemId))
}
We’ll build on this example to explore what in my opinion really makes the dsl shine, the ability to create custom directives through composition.
Suppose we want to use a value class final case class ItemId(value: String) extends AnyVal
for the itemId, that would require us to change the route, one way to do it is this.
def newFetchItem(itemId: ItemId): Future[String]
def wrapItemId(itemId: String): ItemId = ItemId(itemId)
val route: Route = parameter('id) { itemIdString =>
showItem(newFetchItem(wrapItemId(itemIdString)))
}
That’s kind like mapping over parameter('id)
with showItem
.
Now it won’t come as a surprise that a map
exists and there is a flatMap
too, though oddly enough they are only defined for Directive1[T]
.
So another way to rewrite our example would have been
parameter('id).map[ItemId](wrapItemId).apply { itemId =>
onComplete(fetchItem(itemId)) {
case Success(thing) => complete(thing)
case Failure(error) => failWith(error)
}
}
To bring it all together, a directive that would bring the item in scope instead of responding with it can be written like this
parameter('id)
.map(wrapItemId)
.map(fetchItem)
.flatMap(onComplete)
.flatMap { // from Try[String] to a Directive1[String]
case Success(item) => provide(item)
case Failure(error) => failWith(error)
}
Conclusion
Akka-http already defines a ton of useful directives, a list of which can be found here. But with abstraction over composed primitives and composites we are now able to create very exact and expressive routing that makes sense for the contexts of our projects.