📜 ⬆️ ⬇️

Pure-functional REST API on Finagle / Finch

Finch


The history of the Finch library began about a year ago “in the basements” 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 abstractions. They were supposed to be immutable, composable and at the same time very simple. Simple as a function. This is how 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 pleasant and simple.

Six months ago , the first stable version of the library was released, and just recently , version 0.5.0 was released , which I personally consider to be 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 ) began using Finch in production, and some of them even became active contributors.
')
This post tells about three whales on which Finch is built: Router , RequestReader and ResponseBuilder .

Router


The io.finch.route package implements the route combinators API, which allows you to build an infinite number of routers, combining them from primitive routers accessible from the box. Parser Combinators and scodec use the same approach.

In a sense, Router[A] is a function of Route => Option[(Route, A)] . Router takes the abstract Route route and returns Option from the remaining route and the extracted value of type A In other words, Router returns Some(...) in case of success (if the request was able to route it).

There are only 4 basic routers: int , long , string and boolean . In addition, there are routers that do not extract the value from the route, but simply associate it with a sample (for example, routers for HTTP methods: Get , Post ).

The following example shows the API for the composition of routers. The router routes the GET /(users|user)/:id requests and extracts the integer id value from the route. Pay attention to the operator / (or andThen ), with the help of which we sequentially compose two routers, and also on the operator | (or orElse ), which allows you to compose two routers in terms of logical or .

 val router: Router[Int] => Get / ("users" | "user") / int("id") 

If the router needs to extract several values, you can use the 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] is just a type alias on Router[Service[Req, Rep]] ). Endpoint s 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


The io.finch.request.RequestReader is key in Finch. Obviously, most of the REST API (without taking into account business logic) is reading and validating the request parameters. This is RequestReader . Like everything else in Finch, RequestReader[A] is a HttpRequest => Future[A] function. Thus, RequestReader[A] accepts an HTTP request and reads some value of type A from it. The result is placed in the Future , primarily in order to represent the stage of reading the parameters as an additional Future transformation (as a rule, the first) in the data-flow of the service. Therefore, if 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 “title” parameter or returns a NotPresent exception if the parameter is missing 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 io.finch.request package provides a rich set of built-in RequestReader s for reading various information from an HTTP request: starting from query-string parameters and ending with cookies. All available RequestReader -y are divided into two groups - required (required) and optional (optional). Required readers read the value or exception NotPresent , optional - Option[A] .

 val firstName: RequestReader[String] = RequiredParam("fname") val secondName: RequestReader[Option[String]] = OptionalParam("sname") 

As in the case of route combinators, RequestReader provides an API with which you can compose two readers into one. There are two APIs: monadic (using flatMap ) and applicative (using ~ ). While monadic syntax looks familiar, it is highly recommended to use applicative syntax, which allows you to accumulate errors, while the fail-fast nature of monads returns only the first one. The example below shows both ways of composing a reader.

 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("")) } 

Among other things, RequestReader allows RequestReader to read from the request values ​​of types other than String. You can convert the readable value using the RequestReader.as[A] method.

 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 basis of the magic of the as[A] method is an implicit DecodeRequest[A] type parameter. Type-class DecodeRequest[A] carries information about how type A can be obtained from a String . In case of conversion error, RequestReader will read NotParsed exception. Out of the box, conversions to Int , Long , Float , Double and Boolean types are supported.

JSON support in RequestReader implemented in the same way: we can use the as[Json] method if for Json there is an implementation of DecodeRequest[Json] in the current scope. In the example below, RequestReader user reads a user that is serialized in JSON format to the body of the HTTP request.

 val user: RequestReader[Json] = RequiredBody.as[Json] 

Given the support of the Jackson JSON library, reading JSON objects using RequestReader greatly simplified.

 import io.finch.jackson._ case class User(name: String, age: Int) val user: RequestReader[User] = RequiredBody.as[User] 

Validation of request parameters is carried out using the methods RequestReader.should and RequestReader.shouldNot . There are two ways of validation: using inline rules and using ready-made ValidationRule . In the example below, the age reader reads the “age” parameter, provided it is greater than 0 and less than 120. Otherwise, the reader will read the NotValid exception.

 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 io.finch.request package and the io.finch.request composers and from the ValidationRule .

 val age: RequestReader[Int] = RequiredParam("age").as[Int] should (beGreaterThan(0) and beLessThan(120)) 

ResponseBuilder


The io.finch.response package provides a simple API for building HTTP responses. It is considered common practice to use a specific ResponseBuilder corresponding to the 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 io.finch.response package is type-class EncodeResponse[A] . ResponseBuilder is able to build HTTP responses from any type A , if for it there is an implicit value EncodeResponse[A] in the current scope. This is how JSON support is implemented in ResponseBuilder : for each supported library there is an implementation of EncodeResponse[A] . The following code shows integration with the standard JSON implementation from the finch-json module.

 import io.finch.json._ val response = Ok(Json.obj("name" -> "John", "id" -> 0)) // application/json response 

Thus, you can extend the functionality of ResponseBuilder adding implicit EncodeResponse[A] values ​​for the required type to the current scope. 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 only a tool that some developers consider effective for the tasks they are working on. I hope that this publication will become a starting point for Russian-speaking programmers who have decided to use / try Finch in their projects.

Source: https://habr.com/ru/post/250591/


All Articles