http4s-from-cats-to-zio

Hey-hey-hey! So you decided that it’s not cool to use http4s with Cats any more. It’s obvious that you may want to see in action ZIO as a more progressive Scala library for functional programming. Fortunately it’s easy to start using ZIO in your http4s app, and I’ve seen personally several articles about that. But I decided to write my own recipe 🙂

As a base http4s project with cats I’m going to use a project which I described in one of my previous articles. It’s a trivial REST API with basic CRUD operations, so it will not take too much time to get into the context. As first step we need to add two extra dependencies to build.sbt:

...
  "dev.zio"  %% "zio"               % "1.0.0-RC12-1",
  "dev.zio"  %% "zio-interop-cats"  % "2.0.0.0-RC2"
...

zio is required for basic ZIO data types, while zio-interop-cats contains all required “adapters” between ZIO and Cats. Keep an eye on versions because for now (Autumn 2019) ZIO community releases updates so frequently that it’s hard to stay up to date.

Next step is to change cats IO on some equivalent from ZIO. And I’m talking about zio.Task. It’s a data type which represents an effect that may produce a value A or fail with Throwable. Once again, Taks has only two possible outcomes: if an effect was successful then it returns A otherwise it returns a failure as a subclass of Throwable.

import cats.implicits._
import zio.Task

trait BookRepo {

  def addBook(book: Book): Task[BookId]
  def getBook(id: BookId): Task[Option[BookWithId]]
  def deleteBook(id: BookId): Task[Either[Message, Unit]]
  def updateBook(id: BookId, book: Book): Task[Either[Message, Unit]]
  def getBooks(): Task[List[BookWithId]]

}

object BookRepo {

  class DummyImpl extends BookRepo {

    import scala.collection.mutable.HashMap
    val storage = HashMap[BookId, Book]().empty

    override def addBook(book: Book): Task[BookId] = Task {
      val bookId = BookId()
      storage.put(bookId, book)
      bookId
    }

    override def getBook(id: BookId): Task[Option[BookWithId]] = Task {
      storage.get(id).map(book => BookWithId(id.value, book.title, book.author))
    }

    override def deleteBook(id: BookId): Task[Either[Message, Unit]] =
      for {
        removedBook <- Task(storage.remove(id))
        result = removedBook.toRight(s"Book with ${id.value} not found").void
      } yield result

    override def updateBook(id: BookId, book: Book): Task[Either[Message, Unit]] = {
      for {
        bookOpt <- getBook(id)
        _ <- Task(bookOpt.toRight(s"Book with ${id.value} not found").void)
        updatedBook = storage.put(id, book)
                        .toRight(s"Book with ${id.value} not found").void
      } yield updatedBook
    }

    override def getBooks(): Task[List[BookWithId]] = Task {
      storage.map {case (id, book) => BookWithId(id.value, book.title, book.author)}.toList
    }
  }

}

Feel free to stop here and compare 2 versions of BookRepo. First one with Cats from the previous article, and the second one you recently scrolled through.

When BookRepo is updated, we can proceed with updating a code where routes are described. In addition to zio.Task, I add import of zio.interop.catz._:

import zio.Task

import zio.interop.catz._
import io.circe.generic.auto._
import org.http4s.circe.CirceEntityCodec._

object BookRoutes {

  private def errorBody(message: Message) = Json.obj(
    ("message", Json.fromString(message))
  )

  def routes(bookRepo: BookRepo): HttpRoutes[Task] = {

    val dsl = new Http4sDsl[Task]{}
    import dsl._

    HttpRoutes.of[Task] {
      case _ @ GET -> Root / "books" =>
        bookRepo.getBooks().flatMap(books => Ok(books))

      case req @ POST -> Root / "books" =>
        req.decode[Book] { book =>
          Book.validator.validate(book) match {
            case None =>
              bookRepo.addBook(book) flatMap(id =>
                Created(Json.obj(("id", Json.fromString(id.value))))
              )
            case Some(errors) => BadRequest(errors)
          }
        }

      case _ @ GET -> Root / "books" / id =>
        bookRepo.getBook(BookId(id)) flatMap {
          case None => NotFound()
          case Some(book) => Ok(book)
        }

      case req @ PUT -> Root / "books" / id =>
        req.decode[Book] { book =>
          bookRepo.updateBook(BookId(id), book) flatMap {
            case Left(message) => NotFound(errorBody(message))
            case Right(_) => Ok()
          }
        }

      case _ @ DELETE -> Root / "books" / id =>
        bookRepo.deleteBook(BookId(id)) flatMap {
          case Left(message) => NotFound(errorBody(message))
          case Right(_) => Ok()
        }
    }
  }

}

You may already noticed that migration from Cats to ZIO implies almost one thing — change IO to Task. It’s not so, especially if you do it first time and had no prior experience with ZIO. You alway may miss some imports or forget to pass ZIO type class in some constructor.

As a final accord, we need to change a program entry point:

import zio.{Task, ZIO}
import zio.interop.catz._
import zio.interop.catz.implicits._


object Main extends CatsApp {

  private val bookRepo: BookRepo = new BookRepo.DummyImpl

  val httpRoutes = Router[Task](
    "/" -> BookRoutes.routes(bookRepo)
  ).orNotFound

  def run(args: List[String]): ZIO[Environment, Nothing, Int] = {

    BlazeServerBuilder[Task]
      .bindHttp(9000, "0.0.0.0")
      .withHttpApp(httpRoutes)
      .serve
      .compile[Task, Task, ExitCode]
      .drain
      .fold(_ => 1, _ => 0)

  }

}

Notice that the Main object extends CatsApp. Also notice how changed return type of run function and how parametrized .compile.

I hope that this article about running http4s app with ZIO will be helpful for somebody, who is looking for a particular example with code snippets as I was looking for it 3 months ago 😉

About The Author

Mathematician, programmer, wrestler, last action hero... Java / Scala architect, trainer, entrepreneur, author of this blog

Close