Creating Custom Directives in Akka HTTP
To tidy up your routes.
If you’ve ever written something more complex than a Hello World API in Akka HTTP, you might have found out that the routing DSL can get quickly out of hand.
With deeply nested layers in your routes, and maybe even some code duplication.
That’s what directives are for. But what is a directive?
Directives are small, composable building blocks that allow you to create your routes piece by piece.
A typical route looks like:
pathPrefix("todos") {
pathEndOrSingleSlash {
get {
onComplete(todoRepository.all()) {
case Success(todos) =>
complete(todos)
case Failure(exception) =>
println(exception.getMessage)
complete(ApiError.generic.statusCode, ApiError.generic.message)
}
}
}
}
The route above accepts GET requests in the path /todos
or /todos/
. It also does some error handling when the future returned by todoRepository.all()
completes.
However, we will need this error handling in most — if not all — of our routes. As it is right now, we would have to copy and paste it all around.
And this is only error handling, there might be other concerns that we’d like to handle in our routes, such as authentication.
So, how can we reuse those error handling bits?
We can create our own custom directives.
Setting up the project
We will work with a base project, make sure to clone it and checkout the 6.3-api-errors
branch to follow along.
Before creating the custom directive, let’s do a quick overview of the project.
The project uses Scala 2.12.6
and SBT 1.1.6
, you can confirm the versions in the build.sbt
and build.properties
files respectively.
The project is an API for a todo application. Running tree src
in the root level of the project gives us the following:
src
├── main
│ └── scala
│ ├── ApiError.scala
│ ├── Main.scala
│ ├── Router.scala
│ ├── Server.scala
│ ├── Todo.scala
│ └── TodoRepository.scala
└── test
└── scala
├── TodoMocks.scala
└── TodoRouterListSpec.scala4 directories, 8 files
Briefly, the classes’ responsibilities are:
- ApiError: model for errors.
- Main: application entry point.
- Router: where our routes live.
- Server: takes a router and binds it to a host and port.
- Todo: application model.
- TodoRepository: handles the CRUD for todos.
Inside the TodoRouter
implementation, you can see the route we discussed above, where we are doing some manual error handling, and we’d like to do the same in the other routes.
If you look at the build.sbt
file, you can see the dependencies we need, the Akka actors, streams and HTTP modules, Circe for JSON and an extra library to use it with Akka HTTP.
Creating custom directives
There are multiple ways of creating custom directives, but we’ll transform existing directives to create new ones.
Create a new trait
under src/main/scala
named TodoDirectives
, it will extend the trait Directives
, and we also need to import Circe and its support library, which we are using for JSON serialization.
import akka.http.scaladsl.server.Directives
trait TodoDirectives extends Directives {
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import io.circe.generic.auto._
}
Let’s create a generic function that will handle a future’s failure, given a function that decides which ApiError
it should return when something unexpected occurs:
def handle[T](f: Future[T])(e: Throwable => ApiError): Directive1[T] =
onComplete(f) flatMap {
case Success(t) =>
provide(t)
case Failure(error) =>
val apiError = e(error)
complete(apiError.statusCode, apiError.message)
}
It’s pretty similar to error handling logic we have in our route, however let’s look at the differences.
We receive a function that receives a Throwable
and returns an ApiError
, this will allow us to handle this externally, and make the directive more generic.
Also, we use flatMap
because we are transforming the directives to create new ones, we are not applying them.
If the future succeeds we provide
the value, which is why our return type is a Directive1[T]
, our directive will return or provide one value, which is the value contained in the future.
However, if the future fails we get the ApiError
from the function, and we send the response back to the client with the respective status code and message.
If you look inside the ApiError.scala
file, we only have one generic error. Right now our repository can’t fail in many ways because we are only listing todos.
Let’s create another directive, that no matter what the exception is, it will always reply back with the generic ApiError
:
def handleWithGeneric[T](f: Future[T]): Directive1[T] =
handle[T](f)(_ => ApiError.generic)
Let’s use it to tidy up our error handling in our TodoRouter
. First, we need to extend our TodoDirective
trait to have access to our custom directives.
Then we can update the route, and it will look like:
pathPrefix("todos") {
pathEndOrSingleSlash {
get {
handleWithGeneric(todoRepository.all()) { todos =>
complete(todos)
}
}
} ~ ...
It’s cleaner than the initial one, more importantly, we can now update the other routes with less boilerplate. Our TodoRouter
ends up looking like:
import akka.http.scaladsl.server.{Directives, Route}
trait Router {
def route: Route
}
class TodoRouter(todoRepository: TodoRepository) extends Router with Directives with TodoDirectives {
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import io.circe.generic.auto._
override def route: Route = pathPrefix("todos") {
pathEndOrSingleSlash {
get {
handleWithGeneric(todoRepository.all()) { todos =>
complete(todos)
}
}
} ~ path("done") {
get {
handleWithGeneric(todoRepository.done()) { todos =>
complete(todos)
}
}
} ~ path("pending") {
get {
handleWithGeneric(todoRepository.pending()) { todos =>
complete(todos)
}
}
}
}
}
We can also make sure (and probably should) that our routes still work as expected by either running the TodoRouterListSpec
test, or running sbt test
from the command line inside the project’s root directory.
Running sbt test
should give you an output similar to:
[info] TodoRouterListSpec:
[info] A TodoRouter
[info] - should return all the todos
[info] - should return all the done todos
[info] - should return all the pending todos
[info] - should handle repository failure in the todos route
[info] - should handle repository failure in the done todos route
[info] - should handle repository failure in the pending todos route
[info] Run completed in 1 second, 859 milliseconds.
[info] Total number of tests run: 6
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 6, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 13 s, completed 20-Aug-2018 23:21:12
Which means that all the tests are still passing. Awesome! 👏🏽
If you liked the tutorial, we’ve got a free Akka HTTP course where you’ll build the Todo project we used here from scratch, explained step by step. See you inside! 👇🏽