Pure-functional REST API on Finagle / Finch

    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, RequestReaderand ResponseBuilder.

    Router


    The package io.finch.routeimplements 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)]. Routeraccepts an abstract route Routeand returns Optionfrom the remaining route and the retrieved type value A. In other words, Routerreturns Some(...)if successful (if the request could be routed).

    There are 4 basic router: int, long, stringandboolean. 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 routerroutes view requests GET /(users|user)/:idand 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.RequestReaderis 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 Futuretransformation (usually the first) in the data-flow service. Therefore, if it RequestReaderreturns 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 exampleRequestReadertitlereads the required query-string parameter "title" or returns an exception NotPresentif 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.requestprovides a rich set of built- RequestReaderins for reading various information from an HTTP request: from query-string parameters to cookies. All available RequestReaders 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 RequestReaderprovides 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, RequestReaderit 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 Stringto get a type A. In case of a conversion error, it RequestReaderwill read NotParsedexception. Out of the box supported conversion types Int, Long, Float, Doubleand Boolean.

    In the same way, JSON support is implemented in RequestReader: we can use the method as[Json]if Jsonthere is an implementation DecodeRequest[Json]in the current scope. The example below RequestReaderuserreads 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.shouldand methods RequestReader.shouldNot. There are two ways to validate: using inline rules and using ready-made ones ValidationRule. In the example below, the reader agereads 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.requestand composers or/ andfrom ValidationRule.

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

    ResponseBuilder


    The package io.finch.responseprovides a simple API for building HTTP responses. It is common practice to use a specific ResponseBuilderresponse status code, for example, Okor Created.

    val response: HttpResponse = Created("User 1 has been created") // plain/text response
    

    An important abstraction of the package io.finch.responseis type-class EncodeResponse[A]. ResponseBuilderIt is able to build HTTP responses from any type Aif 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 ResponseBuilderadding 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.


    Also popular now: