REST и Akka HTTP

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

  1. REST API
  2. Akka HTTP
  3. Роутинг запросов
  4. Сериализация и десериализация

REST API

Как строить API поверх HTTP?

Можно инкапсулировать протокол поверх HTTP.

Но это сложно и не позволяет использовать полезные свойства протокола.

Используем, если протокол не "ложится" на логику HTTP.

REST - методика создания API поверх протокола HTTP.

Используем "фичи" протокола по максимуму.

"REST" как функционально программирование - его все любят, но никто точно не знает, что это такое.

Обычно под REST понимают следующее:
  • Отсутствие состояния клиента на сервере,
    запросы независимы, нет "сессий"
  • URI, указывающие на объекты API
  • Методы HTTP - действия над объектами
  • Ошибки с кодами HTTP
  • Использование кеширования и условных запросов
  • Content-type для выбора формата и версионирования

Akka HTTP

Современный асинхронный HTTP сервер и клиент.

(основное использование - серверная сторона)

Никаких сервлетов, "веб-приложений" и т.п.

Построен на технологиях Akka.

  • akka-io - неблокирующийся ввод-вывод
    (акторы + java nio)
  • akka-streams - реактивные потоки

Akka HTTP - веб-сервер на Akka, а не "для Akka".

Асинхронность - Future и Akka Streams.
Акторы используются только внутри реализации.

(будет отдельная лекция про Akka Streams)

Java-сервлеты научились быть асинхронными через 10 лет после появления первой версии.

И еще через 3 года появился
асинхронный ввод-вывод.

Начнем с Hello, world!

build.sbt


libraryDependencies +=
  "com.typesafe.akka" %% "akka-http" % "10.2.9"

// версию Akka выбираем сами (>= 2.5)
libraryDependencies +=
  "com.typesafe.akka" %% "akka-stream" % "2.6.19"
					

Импортируем scaladsl


import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
					

(не путаем с javadsl, Java API)

Запуск сервера

// ActorSystem - ExecutionContext, Scheduler и др компоненты
implicit val system: ActorSystem = ActorSystem("my-system")

// обработчик запросов
val route: Route = ???

val bindingFuture: Future[Http.ServerBinding] = 
  Http().newServerAt("localhost", 8080).bind(route)

// не забываем про обработку bindingFuture:
// там могут быть ошибки
					

Hello, World!


val route: Route =
  path("hello") { // путь "/hello"
    get { // метод "GET"
      complete(
        HttpEntity( // по-умолчанию тип text/plain для String
          ContentTypes.`text/html(UTF-8)`, 
	  "

Say hello to akka-http

")) } }

Обработчик запросов


type Route = RequestContext => Future[RouteResult]

// есть еще более низкоуровневый вариант
					

Стандартный роутинг (плюсы):

  • Максимально гибкий, API любой сложности.
    Любая комбинация условий.
  • Неограниченная композиция
  • Легко расширяется своими условиями

Стандартный роутинг (минусы):

  • Переусложнен, трудная навигация в коде
  • IDEA тормозит (много implicit conversion)
  • Ручное форматирование кода
    (автоформатирование плохо работает)
  • Нет генерации документации

Альтернативный роутинг:

  • Play Framework, несколько вариантов
  • Tapir https://github.com/softwaremill/tapir
    универсальный DSL, генерация OpenAPI
  • ... еще много реализаций, ищите на github

Вернемся к REST:
URI должен указывать на объект на сервере

Например, профиль пользователя admin
/users/admin

Действия над пользователями:

  • GET /users - получить список пользователей
  • POST /users - создать нового
  • GET /users/:login - получить свойства пользователя
  • PUT /users/:login - создать/обновить пользователя
    идемпотентен, все данные в теле
  • PATCH /users/:login - модифицировать пользователя
  • DELETE /users/:login - удалить пользователя

Коды HTTP классифицируют ошибки
(подробности - в теле ответа)

Пример ошибок клиента

  • 400 Bad Request - некорректный запрос или ошибка валидации
  • 403 Forbidden - запрещено правами доступа
  • 404 Not Found - объект отсутствует на сервере
  • 409 Conflict - несовместимое изменение
  • 429 Too Many Requests - слишком много запросов

Ошибки сервера

  • 500 Internal Server Error
  • 503 Service Unavailable

Роутинг


type Route = RequestContext => Future[RouteResult]
					

RouteResult - ADT

  • Complete(response: HttpResponse)
  • Rejected(rejections: Seq[Rejection])

Complete с ошибкой "финален", Rejected допускает альтернативу

Роутинг может быть такой:


    path("users") {
      post {
        entity(as[String]) { login =>
          createNewUser(login)
          complete(StatusCodes.Created)
        }
      } ~ 
      path(Segment) { login =>
        get { ??? } ~
        put { ??? } ~
        patch { ??? } ~
        delete { ??? }
      }
    } 

Элементы роутинга - директивы (Directive).

Функции, оборачивающие другие функции роутинга.

Directive может:

  • Модифицировать запрос
  • Фильтровать запросы по условию
  • Извлекать данные из запроса и контекста
  • Модифицировать ответ
  • Создавать ответ

abstract class Directive[L](implicit val ev: Tuple[L]) {
  // tuple-apply, "низкий уровень"
  def tapply(f: L => Route): Route

  ... // функции модификации и композиции
}
					

tapply извлекает аргументы из tuple

  • Directive0 - без параметров
  • Directive1 - с 1-м параметром
  • Directive2 - с 2-мя
  • ...

Пример:


path("hello") { // Directive0
  get { // Directive0 
    complete("Say hello to akka-http")
  }
} ~  
// Directive1 требует путь "order/$id" и извлекает id
path("order" / IntNumber) { id =>
  complete(s"Order $id")
}
					

Композиция:


path("order" / IntNumber) { id =>
  (get | put) {
    extractMethod { m =>
      complete(s"Received ${m.name} request for order $id")
    }
  }
}
					

// соберем custom директиву
val orderGetOrPutWithMethod =
  path("order" / IntNumber) & (get | put) & extractMethod

val route =
  orderGetOrPutWithMethod { (id, method) =>
    complete(s"Received ${method.name} request for order $id")
  }

					

Работа с Future


val future: Future[String] = Future.successful("value")

val route =
  path("success") {
    onSuccess(future) { v => // Directive[Tuple1[String]]
      complete(s"Future was completed with $v.")
    }
  }
					

Сериализация


def complete(m: => ToResponseMarshallable): StandardRoute
// implicit преобразование
					

typeclass напрямую не используется
(implicit параметры ломают "магию" routing DSL)


type ToResponseMarshaller[T] = Marshaller[T, HttpResponse]

sealed abstract class Marshaller[-A, +B] {
  def apply(value: A)(implicit ec: ExecutionContext): 
    Future[List[Marshalling[B]]]
  ...
}
					

список вариантов для content negotiation

JSON: akka-http-json

  • Argonaut
  • avro4s
  • AVSystem GenCodec
  • circe
  • Jackson
  • Json4s
  • jsoniter-scala
  • Play JSON
  • uPickle

build.sbt


libraryDependencies +=
  "de.heikoseeberger" %% "akka-http-play-json" % "1.39.2"
					

выходят очень часто - одна версия на все json библиотеки


import de.heikoseeberger.akkahttpplayjson.PlayJsonSupport._

...

  complete(Json.obj("value" -> "Hello, world!"))

...
					

можно передать не JsObject, а всё, для чего есть Writes

Десериализация


case class Data(value: String)
object Data {
  implicit val format: OFormat[Data] = Json.format[Data]
}
					

Format в play-json - комбинация Reads и Writes
OFormat - комбинация Reads и OWrites


get {
  complete(Data("Hello, world"))
} ~ post {
  // entity - Directive1
  // as[Data] ищет implicit FromRequestUnmarshaller[T]
  entity(as[Data]) { data =>
    complete(s"Got ${data.value}")
  }
}
					

Напоминаю: