In this post I will try to use vanilla Finagle to implement the classic a Todo rest service.
When I was writing this I discovered that one often use Finch or Airframe (which are addons to Finagle) when createing a Rest api, however I will try to only use Finagle.
I will look at Finch in a future post.
This is not an expert post about Finagle and it’s echo system, it’s my attempt to get an understanding what Finagle is all about.
If you find this interesting I have written another post where I investigate the Finagle client.
Ok, to get started, lets implement a small Hello world service:
import com.twitter.finagle.{Http, Service, http}
import com.twitter.util.{Await, Future}
object SimpleHttpService extends App {
val service = new Service[http.Request, http.Response] {
def apply(req: http.Request): Future[http.Response] = {
println(s"Received request: $req")
Future.value{
val response = http.Response(req.version, http.Status.Ok)
response.contentString = "Hello world!"
response
}
}
}
val server = Http.serve(":8080", service)
Await.ready(server)
}
This is fairly straightforward, we define that the server should listen on port 8080 and we also create a service that handles the incomming requests.
The most important thing to notice is that the result from the service is a Future[Response]
. This is nice since we can write in a non blocking fashion.
After the server is starting and I go to localhost:8080
in my browser I get the pretty unsurprising result:
The Todo app should support the following API:
GET todo/
POST todo/
GET todo/id
PUT todo/id
DELETE todo/id
It should be possible to send in a request body for the POST and PUT methods:
{"id": 0, "title": "A todo", "completed": false}
The id is optional.
To be able to create a Todo I need to extract the information from the request body. You can get request body as a String from the http.request
.
I feel that working with a String is pretty low level, I would prefer to get the request body as an object instead:
case class Todo(id: Option[Long], title: String, completed: Boolean)
I could not find any built in support in Finagle for transforming the json payload to Scala object so I decided to use the bijection-json
library.
The library give you the ability to define how you transform the payload to an object (Todo
in this case) and back again (i.e. transform a Todo
to a String).
implicit val todoBuilder: Injection[Todo, Map[String, JsonNode]] =
Injection.build[Todo, Map[String, JsonNode]](todoToMap)(mapToTodo)
private def mapToTodo(m: Map[String, JsonNode]): Try[Todo] =
Try(Todo(
m.get("id").map(id => fromJsonNode[Long](id).get),
fromJsonNode[String](m("title")).get,
fromJsonNode[Boolean](m("completed")).get
))
private def todoToMap(t: Todo): Map[String, JsonNode] =
if (t.id.isDefined) {
Map("id" -> toJsonNode(t.id.get), "title" -> toJsonNode(t.title), "completed" -> toJsonNode(t.completed))
} else {
Map("title" -> toJsonNode(t.title), "completed" -> toJsonNode(t.completed))
}
implicit val mapToString: Injection[Map[String, JsonNode], String] =
JsonInjection.toString[Map[String, JsonNode]]
implicit val todoToJsonString: Injection[Todo, String] =
Injection.connect[Todo, Map[String, JsonNode], String](todoBuilder, mapToString)
To be able to handle a Bad request
the payload is transformed to a Try[Todo]
.
I also wrote a small convenience function to make things easier:
def withBody[A](req: Request)(handler: A => ScalaFuture[Try[Body]])(implicit ev: Conversion[String, Try[A]]) = {
req.contentString.as[Try[A]] match {
case Success(a) =>
handler(a)
case Failure(e) =>
ScalaFuture(Failure(new IllegalArgumentException(e)))
}
}
We are using the bijection
library here: req.contentString.as[Try[A]]
. The result from the call will either be a valid object of type A
or a failure.
Below is a code snippet from the service that handles the API methods for listing all Todos and to create a new Todo:
case Root / "todo" =>
req.method match {
case Method.Get =>
context.todoActor
.ask((ref: ActorRef[GetTodosReply]) => ListTodos(ref))
.map(asBody(_))
case Method.Post =>
withBody[Todo](req) { todo =>
context.todoActor
.ask((ref: ActorRef[CreateTodoReply]) => CreateTodo(ref, todo))
.map(asBody(_))
}
case _ =>
ScalaFuture(Failure(new NoSuchMethodError(s"Unknown resource: ${req.path}")))
}
First we match on the path and on the method to resolve which AP method that is called.
The state for the Todo app is inside an Actor
so to handle the requests we send messages to the Actor. Finally we us asBody
another convenience function that get us the response body as a String.
def asBody[A](ta: Reply[A])(implicit ev: Conversion[A, String]): Try[Body] =
ta.payload.map(t => t.as[Body])
So here is the whole service:
def service(context: SystemContext): Service[Request, Response] = (req: Request) => (Path(req.path) match {
case Root / "todo" =>
req.method match {
case Method.Get =>
context.todoActor
.ask((ref: ActorRef[GetTodosReply]) => ListTodos(ref))
.map(asBody(_))
case Method.Post =>
withBody[Todo](req) { todo =>
context.todoActor
.ask((ref: ActorRef[CreateTodoReply]) => CreateTodo(ref, todo))
.map(asBody(_))
}
case _ =>
ScalaFuture(Failure(new NoSuchMethodError(s"Unknown resource: ${req.path}")))
}
case Root / "todo" / Long(id) =>
req.method match {
case Method.Get =>
context.todoActor
.ask((ref: ActorRef[GetTodoReply]) => GetTodo(ref, id))
.map(asBody(_))
case Method.Put =>
withBody[Todo](req) { todo =>
context.todoActor
.ask((ref: ActorRef[ModifyTodoReply]) => ModifyTodo(ref, id, todo))
.map(asBody(_))
}
case Method.Delete =>
context.todoActor
.ask((ref: ActorRef[DeleteTodoReply]) => DeleteTodo(ref, id))
.map(asBody(_))
case _ =>
ScalaFuture(Failure(new NoSuchMethodError(s"Unknown resource: ${req.path}")))
}
case _ =>
ScalaFuture(Failure(new NoSuchMethodError(s"Unknown resource: ${req.path}")))
}).map(toResponse).as[Future[http.Response]]
For me it’s kind of annoying that I had to include the error handling for when calling unknown resources in the service (on three different places):
case _ =>
ScalaFuture(Failure(new NoSuchMethodError(s"Unknown resource: ${req.path}")))
In fact I briefly considered to implement yet another convenience function so that it’s possible to compose the service to hide this, but then I have almost implemented my own small REST wrapper for the Finagle http service and that’s not what I’m after.
Lets investigate what help we can get from Finch
in another post instead!
The different errors are handled in the toResponse
function:
def toResponse(body: Try[Body]): http.Response = body match {
case Success(b) =>
val response = http.Response(Status.Ok)
response.setContentTypeJson()
response.contentString = b
response
case Failure(e: NoSuchMethodError) =>
http.Response(Status.NotFound)
case Failure(e: IllegalArgumentException) =>
http.Response(Status.BadRequest)
case Failure(e: NoSuchElementException) =>
http.Response(Status.NotFound)
case _ =>
http.Response(Status.InternalServerError)
}
For completeness, here is the implementation of the TodoActor
:
def behave(currentId: Long, todos: Map[Long, Todo]): Behavior[Request] = Behaviors.receive { (context, message) =>
message match {
case ListTodos(replyTo) =>
replyTo ! GetTodosReply(Try(todos.values.toList.sortBy(t => t.id.get)))
Behaviors.same
case CreateTodo(replyTo, todo) =>
val newTodo = todo.copy(id = Some(currentId))
replyTo ! CreateTodoReply(Success(newTodo))
behave(currentId + 1, todos + (currentId -> newTodo))
case GetTodo(replyTo, id) =>
val todo = Try(todos(id))
replyTo ! GetTodoReply(todo)
Behaviors.same
case ModifyTodo(replyTo, id, todo) =>
val newTodo = Try(todos(id)).map(_.copy(title = todo.title, completed = todo.completed))
println(newTodo)
replyTo ! ModifyTodoReply(newTodo)
newTodo match {
case Success(t) =>
behave(currentId, todos + (id -> t))
case _ =>
Behaviors.same
}
case DeleteTodo(replyTo, id) =>
val todo = Try(todos(id))
replyTo ! DeleteTodoReply(todo)
todo match {
case Success(_) =>
behave(currentId, todos - id)
case _ =>
Behaviors.same
}
case _ => ???
}
}
Since this is not a post about Actor I will not dwell on the code, I will only point out that I choose to always wrap the payloads in the Replies in a Try
to get an uniform API. As an example consider ListTodos
, this will not fail, however I still wrapp it in a Try
. This to simplify the logic in the service.
When implementing the service I could not find that many good examples of how to develop a Finagle http service. I also found that I had to write more boiler plate code that I would have expected. It could be that I have missed something.
I got the impression that most people use Finch or Airframe. In the next post I will investigate what kind of help I get when introducing Finch
or Airframe
.
All code examples are available at github.
Happy coding!