Recently I implemented some custom routing mechanics for an Http4s app. Sometimes we want to route requests based on
more than just HTTP details. Http4s’ AuthedRoutes serves as a great example for this.
| |
The second route shows an example of using some new DSL syntax. That small as user at the end of the second route
definition does a lot of heavy lifting around user authentication. Even better, as it modifies the route type itself, we
can’t forget to add authentication to a route (which we might if added auth route by route).
For my change I will focus on a relatively neutral change that works with the normal Http4s request and response
types. Let’s work with a simple requirement.
Expose routes to a specific user-agent only.
How might that look? Let’s sketch out a possible DSL. We have something like case [service] using [route] => where;
service— identifies the requesting client.using— our custom DSL, which activates our routing logic.route— the standard Http4s route definition.
In code this ends up looking like this:
| |
To understand how we might add this, we need to explore how Http4s cleverly uses Scala’s pattern match system. When we
pattern match on a case class, Scala invokes a method called unapply to decide if the given value matches. Typically,
that method might look something like this.
| |
In this case the implementation knows how to decompose a Foo into a Int, by using the class’ foo field. Sometimes
we can’t access a field like that (for example, an ADT), so the optional return type allows us to indicate the match
failed. We can define this method explicitly if we want to, as Scala allows us to define our own custom unapply
matchers. Scala refers to these as extractor objects.
Http4s itself has some great examples of when we might want to use extractors. The status matcher Succesful matches
response statuses within the 2xx range, with similar matchers for 4xx and 5xx too. This showcases the first style of
matcher — a way of grouping similar terms in a match.
You can nest Scala’s matchers in the same way that you can nest data. For example;
| |
This nesting also powers the familiar list matcher.
| |
This syntax eliminates the normal bracket syntax we see in favour of something more readable. Seeing this, you might see
how the Http4s DSL takes shape. Using DSL objects (made available via the Htp4sDSL) trait we can string a series of
matchers along to define our expected request structure. Checking the source code gives us an idea of how to structure
our own custom matcher.
The first component of a Http4s route:
| |
It takes as input the inbound request itself and outputs two chunks split from the request; the method and the path.
Through the same syntax as :: that we saw earlier; Scala can match the request as a method on the left and a path on
the right. Scala also allows us to match against specific values, so we can further refine our match with an exact
method. The path however gets fed to another Path matcher, which decomposes the value further.
| |
This path matcher consumes a Path and splits to a String component on its right-hand side. The left-hand side
returns another Path so we can decompose the path string as much as we need to.
You should see a pattern emerging, and we can start implementing our own. Let’s revisit our desired syntax.
| |
We can start building our matcher by looking at our preferred inputs and outputs. In this case we will need:
- Input: Request[F]
- Output: (Service, Request[F])
Our matcher takes an HTTP request and returns it later. This means matchers can continue matching with it. Along with that we also return our service identifier. Our implementation looks something like this:
| |
Notice the
unapplymethod actually has a generic type parameter. The compiler doesn’t allow type-annotated pattern matchers (you can’t docase Foo[Int](ambiguousNumber) => ???). This means the type parameter needs to resolve unambiguously in the match context.
We returned a simple value, but we could do something more interesting. For example, we could;
- create a bespoke DB connection using a user’s details.
- configure request caching.
- return a request with additional tracking information.
We can also get creative with our DSL. We could change the order of the outputs and move our syntax to the match end
instead, or return multiple matches instead. Experimenting with the values you extract in your match brings up some
interesting possibilities. Take Http4s’ ->> matcher as an example.
| |
This extractor yields control back to the caller. That allows the routing to share some common logic while also delegating part of that match back to the user.
This helps create powerful DSLs. They make defining request-dependent service behaviour a breeze. Generally, you should avoid custom syntax if you can help it. This technique can help simplify a lot of code, but it can make it worse too.
Common drawbacks and criticisms of custom DSLs include;
- Confusing or pointlessly obtuse syntax (for example,
|@|from cats). - They can hide too much. Subtle behaviours introduced through syntax can feel “magic”.
- Newcomers need to learn your syntax on top of a language they might not know already.
You and your team decide how far you want to take it. A part of the art of software development includes deciding when to use these kinds of techniques.
“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” ― Antoine de Saint-Exupéry.
Even small uses can benefit from custom matchers, for example, this…
| |
That reduces cramped matchers into something simple and self-documenting.