Pure-functional REST API on Finagle / Finch
The history of the Finch library began about a year ago in the basement of Confettin , where we tried to make a REST API on Finagle . Despite the fact that finagle-http itself is a very good tool, we began to feel an acute shortage of richer abstractions. In addition, we had special requirements for these very abstractions. They were supposed to be immutable, easily composable, and at the same time very simple. Simple as functions. So the Finch library appeared, which is a very thin layer of functions and types on top of finagle-http, which makes the development of HTTP (micro | nano) services on finagle-http more enjoyable and simple.
Six months ago , the first stable version was releasedlibraries, and just the other day version 0.5.0 was released , which I personally consider pre-alpha 1.0.0. During this time, 6 companies (three of them are not yet on the official list: Mesosphere , Shponic and Globo.com ) started using Finch in production, and some of them even became active contributors.
This post talks about the three pillars on which is built Finch:
Router
, RequestReader
and ResponseBuilder
.Router
The package
io.finch.route
implements route combinators API, which allows you to build an infinite number of routers, combining them from primitive routers available from the box. Parser Combinators and scodec use the same approach . In a sense
Router[A]
, it is a function Route => Option[(Route, A)]
. Router
accepts an abstract route Route
and returns Option
from the remaining route and the retrieved type value A
. In other words, Router
returns Some(...)
if successful (if the request could be routed). There are 4 basic router:
int
, long
, string
andboolean
. In addition, there are routers that do not remove the value of the route, but just compare it with a sample (eg, routers for HTTP methods: Get
, Post
). The following example shows the API for composing routers. The router
router
routes view requests GET /(users|user)/:id
and extracts an integer value from the route id
. Pay attention to the operator /
(or andThen
), c through which we consistently kompoziruem two routers, as well as the operator |
(or orElse
) that allows kompozirovat two routers in terms of logic or
.val router: Router[Int] => Get / ("users" | "user") / int("id")
If the router needs to extract multiple values, you can use a special type
/
.case class Ticket(userId: Int, ticketId: Int)
val r0: Router[Int / Int] = Get / "users" / int / "tickets" / int
val r1: Router[Ticket] = r0 map { case a / b => Ticket(a, b) }
There is a special type of routers that extract service (Finagle
Service
) from the route . Such routers are called endpoints (in fact, Endpoint[Req, Rep]
they are just type alias on Router[Service[Req, Rep]]
). Endpoint
-y can be implicitly converted to Finagle services ( Service
), which allows them to be used transparently with the Finagle HTTP API.val users: Endpoint[HttpRequest, HttpResponse] =
(Get / "users" / long /> GetUser) |
(Post / "users" /> PostUser) |
(Get / "users" /> GetAllUsers)
Httpx.serve(":8081", users)
RequestReader
Abstraction
io.finch.request.RequestReader
is key in Finch. Obviously, most of the REST API (excluding business logic) is reading and validating query parameters. This is what he does RequestReader
. Like everything in Finch, RequestReader[A]
it's a feature HttpRequest => Future[A]
. Thus, it RequestReader[A]
accepts an HTTP request and reads some type value from it A
. The result is placed in Future
, first of all, in order to represent the stage of reading parameters as an additional Future
transformation (usually the first) in the data-flow service. Therefore, if it RequestReader
returns Future.exception
, no further transformations will be performed. This behavior is extremely convenient in 99% of cases when the service should not do any real work if one of its parameters is invalid. In the following example
RequestReader
title
reads the required query-string parameter "title" or returns an exception NotPresent
if the parameter is not in the request.val title: RequestReader[String] = RequiredParam("title")
def hello(name: String) = new Service[HttpRequest, HttpResponse] {
def apply(req: HttpRequest) = for {
t <- title(req)
} yield Ok(s"Hello, $t $name!")
}
The package
io.finch.request
provides a rich set of built- RequestReader
ins for reading various information from an HTTP request: from query-string parameters to cookies. All available RequestReader
s are divided into two groups - mandatory (required) and optional (optional). Mandatory readers read the value or exception NotPresent
, optional readers Option[A]
.val firstName: RequestReader[String] = RequiredParam("fname")
val secondName: RequestReader[Option[String]] = OptionalParam("sname")
As with route combinators, it
RequestReader
provides an API that can be used to compose two readers into one. There are two such APIs: monadic (using flatMap
) and applicative (using ~
). While monadic syntax looks familiar, it is highly recommended that you use applicative syntax, which allows you to accumulate errors, while the fail-fast nature of monads returns only the first of them. The example below shows both methods for composing readers.case class User(firstName: String, secondName: String)
// the monadic style
val monadicUser: RequestReader[User] = for {
firstName <- RequiredParam("fname")
secondName <- OptionalParam("sname")
} yield User(firstName, secondName.getOrElse(""))
// the applicate style
val applicativeUser: RequestReader[User] =
RequiredParam("fname") ~ OptionalParam("sname") map {
case fname ~ sname => User(fname, sname.getOrElse(""))
}
In addition,
RequestReader
it allows you to read values from types other than String from a query. You can convert the read value using the method RequestReader.as[A]
.case class User(name: String, age: Int)
val user: RequestReader[User] =
RequiredParam("name") ~
OptionalParam("age").as[Int] map {
case name ~ age => User(fname, age.getOrElse(100))
}
The method magic is based
as[A]
on an implicit type parameter DecodeRequest[A]
. Type-class DecodeRequest[A]
carries information on how String
to get a type A
. In case of a conversion error, it RequestReader
will read NotParsed
exception. Out of the box supported conversion types Int
, Long
, Float
, Double
and Boolean
. In the same way, JSON support is implemented in
RequestReader
: we can use the method as[Json]
if Json
there is an implementation DecodeRequest[Json]
in the current scope. The example below RequestReader
user
reads a user who is serialized in JSON format in the body of an HTTP request.val user: RequestReader[Json] = RequiredBody.as[Json]
Given the Jackson JSON library support , reading JSON objects with
RequestReader
-a is greatly simplified.import io.finch.jackson._
case class User(name: String, age: Int)
val user: RequestReader[User] = RequiredBody.as[User]
Validation of query parameters is carried out using the
RequestReader.should
and methods RequestReader.shouldNot
. There are two ways to validate: using inline rules and using ready-made ones ValidationRule
. In the example below, the reader age
reads the “age” parameter, provided that it is greater than 0 and less than 120. Otherwise, the reader will read the exception NotValid
.val age: RequestReader[Int] = RequiredParam("age").as[Int] should("be > than 0") { _ > 0 } should("be < than 120") { _ < 120 }
The example above can be rewritten in a more concise style, using ready-made rules from the package
io.finch.request
and composers or
/ and
from ValidationRule
.val age: RequestReader[Int] = RequiredParam("age").as[Int] should (beGreaterThan(0) and beLessThan(120))
ResponseBuilder
The package
io.finch.response
provides a simple API for building HTTP responses. It is common practice to use a specific ResponseBuilder
response status code, for example, Ok
or Created
.val response: HttpResponse = Created("User 1 has been created") // plain/text response
An important abstraction of the package
io.finch.response
is type-class EncodeResponse[A]
. ResponseBuilder
It is able to build HTTP responses from any type A
if it has an implicit value EncodeResponse[A]
in the current scope. So JSON support is implemented in ResponseBuilder
: for every supported library there is an implementation EncodeResponse[A]
. The following code shows integration with a standard JSON implementation from a module finch-json
.import io.finch.json._
val response = Ok(Json.obj("name" -> "John", "id" -> 0)) // application/json response
Thus, it is possible to expand the functionality by
ResponseBuilder
adding an implicit value EncodeResponse[A]
to the current scope for the desired type. For example, for type User
.case class User(id: Int, name: String)
implicit val encodeUser = EncodeResponse[User]("applciation/json") { u =>
s"{\"name\" : ${u.name}, \"id\" : ${u.id}}"
}
val response = Ok(User(10, "Bob")) // application/json response
Conclusion
Finch is a very young project that is definitely not a “silver bullet” devoid of “critical flaws”. This is just a tool that some developers find effective for the tasks they work on. I hope that this publication will be the starting point for Russian-speaking programmers who decide to use / try Finch in their projects.