Создание простого REST сервера на http4s и doobie

Дата: September 9, 2017 Автор: BeiZero
Tags: scala, scalaz, http4s

В этой статье я расскажу о базовых принципах работы с библиотекой http4s и о том как с помощью неё создать простой REST сервер.

“Hello, world!” на http4s

Для создания каркаса приложения мы можем воспользоваться уже готовым шаблоном используя Giter8. Выполним в консоле команду и заполним основные переменные name, organization, package, scala_version, http4s_version:

sbt -sbt-version 0.13.15 new http4s/http4s.g8

name [http4s quickstart]: http4s-rest-api-example
organization [com.example]: io.github.macs_club
package [io.github.macs-club.http4srestapiexample]:
scala_version [2.12.2]: 2.12.3
http4s_version [0.15.11a]: 0.16.0a

Template applied in ./http4s-rest-api-example

Это команда создаст в директории http4s-rest-api-example следующие файлы:

build.sbt
project/build.properties
src/main/resources/logback.xml
src/main/scala/io/github/macs-club/http4srestapiexample/HelloWorld.scala
src/main/scala/io/github/macs-club/http4srestapiexample/Server.scala

Ну и давайте сразу запустим то, что у нас получилось командой sbt run. После разрешения зависимостей и компиляции мы увидим:

[run-main-0] INFO  o.h.b.c.n.NIO1SocketServerGroup - Service bound to address /0:0:0:0:0:0:0:0:8080
[run-main-0] INFO  o.h.s.ServerApp - Started server on /0:0:0:0:0:0:0:0:8080

Это говорит о том, что blaze, собственный серверный бэкенд http4s, запустил наш сервис на порту 8080. Можем отправить ему “Hello, world!” запрос curl’ом:

bash-3.2$ curl localhost:8080/hello/world!
{"message":"Hello, world!"}

Теперь рассмотрим содержимое файла HelloWorld.scala и разберём как он работает. Основная логика здесь скрывается в переменной helloWorldService типа HttpService. HttpService это просто алиас к Kleisli[Task, Request, Response]. Если вы не понимаете, что это значит, то ничего страшного. В данном случае Kleisli это обёртка над Request => Task[Response] и Task это асинхронная операция.

Для работы с HttpService импортируются зависимости:

import org.http4s._, org.http4s.dsl._

Используя http4s-dsl можно создать HttpService используя сопоставление с образцом для запроса. Давайте посмотрим как устроен сервис, к которому мы обращались в предудещей части статьи, который обрабатывает запрос GET /hello/:name, где :name параметр:

val helloWorldService = HttpService {
  case GET -> Root / "hello" / name =>
     Ok(Json.obj("message" -> Json.fromString(s"Hello, ${name}")))
}

Теперь перейдём к Server.scala и разберём как запускается сервер. http4s поддерживает несколько серверных бэкнендов. В данном случае используется blaze. Внутри файла находится объект BlazeExample который реализует функцию server специального трейта ServerApp предназначенного для работы с сервером. Используя BlazeBuilder в функции server монтируется helloWorldService по корневому пути /, далее передаётся ExecutorService и команда start для запуска сервера. Сервисы можно монтировать в любом порядке, а запрос будет сопоставлять сначала с самым длинным из путей. BlazeBuilder неизменяемый с цепными методами, каждый из которых возвращает новый BlazeBuilder.

override def server(args: List[String]): Task[Server] =
  BlazeBuilder
    .bindHttp(port, ip)
    .mountService(HelloWorld.helloWorldService)
    .withServiceExecutor(pool)
    .start

bindHttp не является необходимым, по умолчанию сервер будет настроен на запуск по localhost:8080. mountService связывает путь с HttpService.

Приложение заметок

Давайте используя http4s создадим простенький REST API для работы со списком заметок. Создадим класс представляющий наши записки:

package io.github.macs_club.http4srestapiexample.domain

case class Note(title: String, body: String = "")

Для работы с базой данных будем использовать библиотеку doobie и встроенную базу данных h2. Для этого добавим в build.sbt необходимые зависимости:

val DoobieVersion = "0.4.4"

libraryDependencies ++= Seq(
  "org.tpolecat"  %% "doobie-core"        % DoobieVersion,
  "org.tpolecat"  %% "doobie-h2"          % DoobieVersion,
  "org.tpolecat"  %% "doobie-specs2"      % DoobieVersion
)

А так же добавим дополнительную зависимость библиотеки circe для автоматического вывода кодеков для json.

val CirceVersion = "0.8.0"

libraryDependencies ++= Seq(
  "io.circe"      %% "circe-generic"      % CirceVersion
)

Для доступа к базе данных будем использовать паттерн репозиторий. Создадим абстрактный класс Repository:

package io.github.macs_club.http4srestapiexample.repository

import scalaz.Monad
import scala.language.higherKinds

abstract class Repository[A, M[_]: Monad] {
  def +=(entity: A): M[A]
  def update(entity: A): M[A]
  def -=(entity: A): M[Unit]
  def list: M[List[A]]
}

Здесь используется обобщённый тип A, который будет соответствовать сущности с которой мы будем работать, и обобщённый тип M являющийся монадой для оборачивания результата работы с базой данных.

Напишем реализацию для нашего типа Note. В качестве монады в которую будем упаковывать результат будем использовать Task:

package io.github.macs_club.http4srestapiexample.repository.impl

import scalaz._, Scalaz._
import doobie.imports._
import doobie.h2.imports._
import scalaz.concurrent.Task

import io.github.macs_club.http4srestapiexample.domain.Note
import io.github.macs_club.http4srestapiexample.repository.Repository

object H2NoteRepository {
  def apply() = Task {
      val h2nr = new H2NoteRepository()
      h2nr.init.unsafePerformSync
      h2nr
  }
}

class H2NoteRepository extends Repository[Note, Task]{

  val xa = H2Transactor[Task]("jdbc:h2:mem:http4srestapiexample;DB_CLOSE_DELAY=-1", "h2username", "h2password")

  private def init = {
      val query = sql"""
        CREATE TABLE IF NOT EXISTS note (
          title VARCHAR(20) NOT NULL UNIQUE,
             body  VARCHAR(20)
        )
      """.update.run
      xa >>= (query.transact(_))
  }

  override def +=(note: Note) = {
      val insertQ = sql"""
            INSERT INTO note (title, body)
            VALUES (${note.title},${note.body})
        """.update.run
    val selectQ = sql"""
            SELECT title, body
            FROM note
        WHERE title = ${note.title}
        """.query[Note].unique
      val query = insertQ *> selectQ
      xa >>= (query.transact(_))
  }

  override def update(note: Note) = {
      val updateQ = sql"""
            UPDATE note
            SET body = ${note.body}
            WHERE title = ${note.title}
        """.update.run
      val selectQ = sql"""
            SELECT title, body
            FROM note
        WHERE title = ${note.title}
        """.query[Note].unique
      val query = updateQ *> selectQ
      xa >>= (query.transact(_))
  }

  override def -=(note: Note) = {
      val query = sql"""
            DELETE FROM note
            WHERE title = ${note.title}
        """.update.run *> FC.unit
      xa >>= (query.transact(_))
  }

  override def list = {
      val query = sql"""
            SELECT title, body
            FROM note
        """.query[Note].list
      xa >>= (query.transact(_))
  }
}

Теперь рассмотрим подробнее что же здесь происходит.

Transactor это обёртка над пуллом соединений. Т.к. у транзактора есть внутренние состояние, то его создание это сайд эффект и его необходимо обернуть, в данном случае используется Task. Т.к. при создании репозитория мы хотим ещё и создать таблицу в которой у нас будут храниться наши сущности, то похожую вещь провернём и с созданием репозитория, т.е. в функции apply объекта компаньона создадим репозиторий, потом создадим таблицу, вернём наш репозиторий и завернём всё это в Task.

Каждому запросу для запуска необходимо передать транзактор. Т.к. транзактор обёрнут в монаду Task то доступ к нему происходит через оператор >>=(алиас для функции flatMap), в который мы передаём вызов функции transact нашего запроса, которая так же возвращает Task.

Запросы +=, update, -= используют комбинирование нескольких запросов с помощью *> и они выполняются их в рамках одной транзакции.

Теперь необходимо создать сервис с помощью которого мы будем с работать с заметками:

package io.github.macs_club.http4srestapiexample.service

import scalaz._, Scalaz._
import io.circe._
import org.http4s._
import org.http4s.circe._
import io.circe.generic.auto._
import org.http4s.dsl._
import scalaz.concurrent.Task

import io.github.macs_club.http4srestapiexample.domain.Note
import io.github.macs_club.http4srestapiexample.repository.Repository
import io.github.macs_club.http4srestapiexample.repository.impl.H2NoteRepository

object NoteService {
  implicit def circeJsonDecoder[A](implicit decoder: Decoder[A]): EntityDecoder[A] = org.http4s.circe.jsonOf[A]
  implicit def circeJsonEncoder[A](implicit encoder: Encoder[A]): EntityEncoder[A] = org.http4s.circe.jsonEncoderOf[A]

  val repository: Task[Repository[Note, Task]] = H2NoteRepository()

  val service = HttpService {
      case GET -> Root => repository >>= (_.list) >>= (Ok(_))
      case req @ POST -> Root => req.decode[Note]{ data =>
         Ok(repository >>= (_ += data))
      }
      case req @ PUT -> Root => req.decode[Note]{ data =>
         Ok(repository >>= (_ update data))
      }
      case DELETE -> Root / name => Ok(repository >>= (_ -= Note(name)))
  }
}

Сервис создаётся на основе репозитория и обрабатывает сообщения на основе сопоставления с образцом. Здесь так же объявлены специальные неявные преобразования предоставляющие EntityDecoder и EntityEncoder для работы с json.

Теперь остаётся только примонтировать новый сервис в BlazeBuilder. Теперь можно запустить сервер и создавать/получать/удалять/изменять записки.

$ curl -X POST localhost:8080 -d '{"title":"Test Title","body":"Test Body"}'
{"title":"Test Title","body":"Test Body"}
$ curl  localhost:8080
[{"title":"Test Title","body":"Test Body"}]
$ curl -X DELETE localhost:8080/Test%20Title
$ curl  localhost:8080
[]

Исходники проекта