Чисто-функциональный REST API на Finagle-Finch

в 4:55, , рубрики: api, finagle, finch, functional programming, rest api, RESTful, scala
Finch

История библиотеки Finch началась около года назад «в подвалах» Конфеттина, где мы пытались сделать REST API на Finagle. Не смотря на то, что finagle-http сам по себе очень хороший инструмент, мы стали ощущать острую нехватку более богатых абстракциий. Кроме того, у нас были особые требования к этим самым абстракциям. Они должны были быть неизменяемыми (immutable), легко композируемыми (composable) и в тоже время очень простыми. Простыми как функции. Так появилась библиотека Finch, которая представляет собой очень тонкий слой функций и типов поверх finagle-http, который делает разработку HTTP (micro|nano)-сервисов на finagle-http более приятной и простой.

Шесть месяцев назад вышла первая стабильная версия библиотеки, а буквально на днях вышла версия 0.5.0, которую я лично считаю pre-alpha 1.0.0. За это время 6 компаний (три из них еще не в официальном списке: Mesosphere, Shponic и Globo.com) начали использовать Finch в production, а некоторые из них даже стали активными контрибьюторами.

Этот пост рассказывает о трех китах на которых построен Finch: Router, RequestReader и ResponseBuilder.

Router

Пакет io.finch.route реализует route combinators API, который позволяет строить бесконечное количество роутеров, комбинируя их из примитивных роутеров, доступных из коробки. Такой же подход используют Parser Combinators и scodec.

В некотором смысле Router[A] — это функция Route => Option[(Route, A)]. Router принимает абстрактный маршрут Route и возвращает Option от оставшегося маршрута и извлеченное значение типа A. Иными словами, Router возвращает Some(...) в случае успеха (если запрос удалось смаршрутизировать).

Есть всего 4 базовых роутера: int, long, string и boolean. Кроме того, есть роутеры, которые не извлекают значение из маршрута, а просто сопоставляют его с образцом (например, роутеры для HTTP методов: Get, Post).

Следующий пример показывает API для композиции роутеров. Роутер router маршрутизирует запросы вида GET /(users|user)/:id и извлекает из маршрута целое значение id. Обратите внимание на оператор / (или andThen), c помощью которого мы последовательно композируем два роутера, а также на оператор | (или orElse), который позволяет композировать два роутера в терминах логического or.

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

Если роутеру требуется извлечь несколько значений, можно использовать специальный тип /.

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

Есть специальный тип роутеров, которые извлекают из маршрута сервис (Finagle Service). Такие роутеры называются endpoints (на самом деле, Endpoint[Req, Rep] — это всего лишь type alias на Router[Service[Req, Rep]]). Endpoint-ы могут быть неявно (implicitly) конвертированы в Finagle сервисы (Service), что позволяет их прозрачно использовать с Finagle HTTP API.

val users: Endpoint[HttpRequest, HttpResponse] =
  (Get / "users" / long /> GetUser) |
  (Post / "users" /> PostUser) |
  (Get / "users" /> GetAllUsers)

Httpx.serve(":8081", users)

RequestReader

Абстракция io.finch.request.RequestReader является ключевой в Finch. Очевидно, что бОльшая часть REST API (без учета бизнес логики) — это чтение и валидация параметров запроса. Этим и занимается RequestReader. Как и все в Finch, RequestReader[A] — это функция HttpRequest => Future[A]. Таким образом, RequestReader[A] принимает HTTP запрос и читает из него некоторое значение типа A. Результат помещается во Future, в первую очередь для того, чтобы представлять этап чтения параметров как дополнительную Future-трансформацию (как правило, первую) в data-flow сервиса. Поэтому, если RequestReader вернет Future.exception, никакие дальнейшие трансформации не будут выполнены. Такое поведение крайне удобно в 99% случаях, когда сервис не должен делать никакую реальную работу если один из его параметров невалиден.

В следующем примере RequestReader title читает обязательный query-string параметр «title» или возвращает исключение NotPresent в случае если парамер отсутствует в запросе.

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

Пакет io.finch.request предоставляет богатый набор встроенных RequestReader-ов для чтения различной информации из HTTP запроса: начиная от query-string параметров и заканчивая cookies. Все доступные RequestReader-ы делятся на две группы — обязательные (required) и необязательные (optional). Обязательные ридеры читают значение или исключение NotPresent, необязательные — Option[A].

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

Как и в случае с route combinators, RequestReader предоставляет API, с помощью которого можно композировать два ридера в один. Таких API два: monadic (используя flatMap) и applicative (используя ~). В то время как monadic syntax выглядит знакомо, крайне рекомендуется использовать applicative syntax, который позволяет накапливать ошибки, в то время как fail-fast природа монад возвращает только первую из них. Пример ниже показывает оба способа композиции ридеров.

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

Кроме всего прочего, RequestReader позволяет читать из запроса значения типов, отличных от String. Можно конвертировать читаемое значение с помощью метода 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))
  }

В основе магии метода as[A] лежит implicit параметр типа DecodeRequest[A]. Type-class DecodeRequest[A] несет информацию о том, как из String можно получить тип A. В случае ошибки конверсии RequestReader прочитает NotParsed exception. Из коробки поддерживаются конверсии в типы Int, Long, Float, Double и Boolean.

Таким же способом реализована поддержка JSON в RequestReader: мы можем использовать метод as[Json], если для Json есть реализация DecodeRequest[Json] в текущей области видимости. В примере ниже RequestReader user читает пользователя, который сериализован в формате JSON в body HTTP запроса.

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

С учетом поддержки JSON библиотеки Jackson, чтение JSON объектов с помощью RequestReader-а сильно упрощается.

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

Валидация параметров запроса осуществляется с помощью методов RequestReader.should и RequestReader.shouldNot. Есть два способа валидации: с помощью inline правил и с помощью готовых ValidationRule. В примере ниже, ридер age читает параметр «age» при условии, что он больше 0 и меньше 120. В противном случае ридер прочитает исключение NotValid.

val age: RequestReader[Int] = RequiredParam("age").as[Int] should("be > than 0") { _ > 0 } should("be < than 120") { _ < 120 }

Пример выше можно переписать в более лаконичном стиле, используя готовые правила из пакета io.finch.request и композиторы or/and из ValidationRule.

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

ResponseBuilder

Пакет io.finch.response предоставляет простой API для построения HTTP ответов. Общей практикой считается использовать конкретный ResponseBuilder, соответствующий статус-коду ответа, например, Ok или Created.

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

Важной абстракцией пакета io.finch.response является type-class EncodeResponse[A]. ResponseBuilder способен строить HTTP ответы из любого типа A, если для него есть implicit значеие EncodeResponse[A] в текущей области видимости. Так реализованна поддержка JSON в ResponseBuilder: для каждой поддерживаемой библиотеки есть реализация EncodeResponse[A]. Следующий код показывает интеграцию со стандартной JSON реализацией из модуля finch-json.

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

Таким образом, можно расширить функционал ResponseBuilder-а, добавив в текущую области видимости implicit значение EncodeResponse[A] для нужного типа. Например, для типа 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

Заключение

Finch — очень молодой проект, который определенно не является «серебряной пулей» лишенной «критических недостатков». Это лишь инструмент, который некоторые разработчики считают эффективным для задач, над которыми они работают. Надеюсь, что эта публикация станет отправной точкой для русскоговорящих программистов, решивших использовать/попробовать Finch в своих проектах.

Автор: spiff

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js