Routes
“Routes” are a central concept in spray-routing since all structures you build with the routing DSL are subtypes of
type Route
. In spray-routing a route is defined like this:
type Route = RequestContext => Unit
It’s a simple alias for a function taking a RequestContext
as parameter.
Contrary to what you might initially expect a route does not return anything. Rather, all response processing
(i.e. everything that needs to be done after the route itself has handled a request) is performed in
“continuation-style” via the responder
of the RequestContext
. If you don’t know what this means, don’t worry.
It’ll become clear soon. The key point is that this design has the advantage of being completely non-blocking as well
as actor-friendly since, this way, it’s possible to simply send off a RequestContext
to another actor in a
“fire-and-forget” manner, without having to worry about results handling.
Generally when a route receives a request (or rather a RequestContext
for it) it can do one of three things:
- Complete the request by calling
requestContext.complete(...)
- Reject the request by calling
requestContext.reject(...)
- Ignore the request (i.e. neither complete nor reject it)
The first case is pretty clear, by calling complete
a given response is sent to the client as reaction to the
request. In the second case “reject” means that the route does not want to handle the request. You’ll see further down
in the section about route composition what this is good for. The third case is usually an error. If a route does not
do anything with the request it will simply not be acted upon. This means that the client will not receive a response
until the request times out, at which point a 500 Internal Server Error
response will be generated.
Therefore your routes should usually end up either completing or rejecting the request.
Constructing Routes
Since routes are ordinary functions RequestContext => Unit
, the simplest route is:
ctx => ctx.complete("Response")
or shorter:
_.complete("Response")
or even shorter (using the complete directive):
complete("Response")
All these are different ways of defining the same thing, namely a Route
that simply completes all requests with a
static response.
Even though you could write all your application logic as one monolithic function that inspects the RequestContext
and completes it depending on its properties this type of design would be hard to read, maintain and reuse.
Therefore spray-routing allows you to construct more complex routes from simpler ones through composition.
Composing Routes
There are three basic operations we need for building more complex routes from simpler ones:
- Route transformation, which delegates processing to another, “inner” route but in the process changes some properties of either the incoming request, the outgoing response or both
- Route filtering, which only lets requests satisfying a given filter condition pass and rejects all others
- Route chaining, which tries a second route if a given first one was rejected
The last point is achieved with the simple operator ~
, which is available to all routes via a “pimp”, i.e. an
implicit extension. The first two points are provided by so-called Directives, of which a large number is
already predefined by spray-routing and which you can also easily create yourself.
Directives deliver most of spray-routings power and flexibility.
The Routing Tree
Essentially, when you combine directives and custom routes via nesting and the ~
operator, you build a routing
structure that forms a tree. When a request comes in it is injected into this tree at the root and flows down through
all the branches in a depth-first manner until either some node completes it or it is fully rejected.
Consider this schematic example:
val route =
a {
b {
c {
... // route 1
} ~
d {
... // route 2
} ~
... // route 3
} ~
e {
... // route 4
}
}
Here five directives form a routing tree.
- Route 1 will only be reached if directives
a
,b
andc
all let the request pass through. - Route 2 will run if
a
andb
pass,c
rejects andd
passes. - Route 3 will run if
a
andb
pass, butc
andd
reject.
Route 3 can therefore be seen as a “catch-all” route that only kicks in, if routes chained into preceding positions reject. This mechanism can make complex filtering logic quite easy to implement: simply put the most specific cases up front and the most general cases in the back.