1. Introduction

In this tutorial, we’ll examine ZIO-HTTP, a Scala library for building and consuming HTTP services initially implemented by Dream11 Engineering. ZIO-HTTP was donated to the ZIO organization in 2022.

Its syntax is purely functional and built on top of ZIO and Netty.

2. Setup

To use ZIO-HTTP, we have to include zio-http in our sbt file:

libraryDependencies += "dev.zio" %% "zio-http" % "3.0.0-RC2"

3. Server

After the minimal setup, it’s time to create and deploy a simple application.

The CounterHttpApp is a concurrent stateful counter application. To handle the application’s state, we’ll use a ZIO Ref, which in turn is collected by the HTTP interface.

ZIO Ref is a mutable reference that holds immutable data, so in our example, it’ll hold the counter value:

object ZioEffectApp extends ZIOAppDefault:
  private object CounterHttpApp:
    def apply(): Http[Ref[Int], Nothing, Request, Response] =
      Http.collectZIO[Request] {
        case Method.GET -> Root / "up" =>
          ZIO.serviceWithZIO[Ref[Int]](cRef =>
            response(cRef.updateAndGet(_ + 1))
          )
        case Method.GET -> Root / "get" =>
          ZIO.serviceWithZIO[Ref[Int]](cRef => response(cRef.get))
        case Method.GET -> Root / "reset" =>
          ZIO.serviceWithZIO[Ref[Int]](cRef =>
            response(cRef.updateAndGet(_ => 0))
          )
      }

    private def response(counterUio: UIO[Int]) = counterUio
      .map(_.toString)
      .map(Response.text)

  def run: ZIO[Environment with ZIOAppArgs with Scope, Throwable, Any] =
    Server
      .serve(CounterHttpApp())
      .provide(
        ZLayer.fromZIO(Ref.make(0)),
        Server.defaultWithPort(8080)
      )

To verify that our service is live, let’s send some curl requests:

~$ curl localhost:8080/get
0
~$ curl localhost:8080/up
1
~$ curl localhost:8080/up
2
~$ curl localhost:8080/reset
0
~$

4. Simple RESTful API

In this section, we’ll create a simple CRUD API for a resource using a ZIO application deployed on a ZIO-HTTP server.

The resource that our API will handle is Recipe:

case class Recipe(id: Long, name: String, ingredients: List[String])

Of course, we need to supply the encode and decode implicit methods in the companion object:

object Recipe:
  given JsonEncoder[Recipe] = DeriveJsonEncoder.gen[Recipe]
  given JsonDecoder[Recipe] = DeriveJsonDecoder.gen[Recipe]

Also, the RecipeService, which is our business layer, is backed by a RecipeRepo implementation. For our example, we decided to implement the InMemoryRecipeRepo since database persistence is outside of our scope.

To demonstrate the HTTP handler composition, we’ll define one HTTP handler for every REST API operation in the following sub-sections.

Also, the addition of the type RecipeEffect makes the HTTP handlers code more brief:

private type RecipeEffect = ZIO[RecipeService, Throwable, Response]

Even if RecipeEffect here is just a type that will allow us to write less code, it’s a good opportunity to expand a bit on ZIO effects.

Essentially, every instance of the ZIO type is a functional effect that represents the three building blocks that comprise any given computation flow. Specifically, those building blocks are the environment type, the failure type, and the success type. In our example, the RecipeEffect has the RecipeService as its environment, the Throwable as its failure type, and the HTTP Response as its success.

Also, to comply with the DRY principle, we added the jsonErrorResponse function for the cases that the request body can’t be parsed:

private def jsonErrorResponse(error: String) = {
  ZIO
    .debug(s"Failed to parse the input: $error")
    .as(
      Response.text(error).withStatus(Status.BadRequest)
    )
}

4.1. POST

First, let’s implement the POST recipe handler:

val postHandler: PartialFunction[Request, RecipeEffect] = {
  case req @ (Method.POST -> Root / RecipeHttpApp.appContext) =>
    (for {
      u <- req.body.asString.map(_.fromJson[Recipe])
      response <- u match {
        case Left(e) => jsonErrorResponse(e)
        case Right(recipe) =>
          ZIO
            .serviceWithZIO[RecipeService](_.save(recipe))
            .map(recipe => Response.json(recipe.toJson))
      }
    } yield response).orDie
}

Essentially, the postHandler function validates and saves the recipe JSON body that was sent, implementing the create portion of our CRUD.

4.2. GET

After creating the POST handler, the next logical step is to define the GET recipe handler so that we can fetch the recipes we created earlier:

val getHandler: PartialFunction[Request, RecipeEffect] = {
  case Method.GET -> Root / RecipeHttpApp.appContext / id =>
    (for {
      idLong <- ZIO.fromTry(Try(id.toLong))
      response <- ZIO
        .serviceWithZIO[RecipeService](_.find(idLong))
        .map({
          case Some(recipe) => Response.json(recipe.toJson)
          case None         => Response.status(Status.NotFound)
        })
    } yield response).orDie
}

In the above function, we validate the path parameter named id conveniently with the ZIO.fromTry helper that transforms a Try to an effect. After the validation process, we simply fetch the recipe through the RecipeService to generate the response.

4.3. PUT

Of course, any recipe updates are handled by the PUT recipe handler:

private val putHandler: PartialFunction[Request, RecipeEffect] = {
  case req @ Method.PUT -> Root / RecipeHttpApp.appContext =>
    (for {
      u <- req.body.asString.map(_.fromJson[Recipe])
      response <- u match {
        case Left(e) => jsonErrorResponse(e)
        case Right(recipe) =>
          ZIO
            .serviceWithZIO[RecipeService](_.update(recipe))
            .map({
              case Some(recipe) => Response.json(recipe.toJson)
              case None         => Response.status(Status.NotFound)
            })
      }
    } yield response).orDie
}

From the ZIO perspective, nothing new was used in the putHandler. As expected, we validate the JSON body, and then we call the update RecipeService update function.

4.4. DELETE

Finally, the delete handler deletes any unwanted recipes:

private val deleteHandler: PartialFunction[Request, RecipeEffect] = {
  case Method.DELETE -> Root / RecipeHttpApp.appContext / id =>
    (for {
      idLong <- ZIO.fromTry(Try(id.toLong))
      response <- ZIO
        .serviceWithZIO[RecipeService](_.delete(idLong))
        .map({
          case Some(recipe) => Response.json(recipe.toJson)
          case None         => Response.status(Status.NotFound)
        })
    } yield response).orDie
}

The deleteHandler validates the request and delegates the execution to the service layer.

4.5. Handlers Combination

It’s important to note that with the HTTP.collectZIO function, every HTTP handler we defined above results in a ZIO HTTP application.

However, in our case, we combined the handlers into a single application:

def apply(): Http[RecipeService, Throwable, Request, Response] =
  Http.collectZIO[Request] { postHandler } ++
    Http.collectZIO[Request] { getHandler } ++
    Http.collectZIO[Request] { putHandler } ++
    Http.collectZIO[Request] { deleteHandler }

4.6. Deployment

With the building blocks in place, now is the time to deploy the RecipeHttpApp on the Netty server:

Server
  .serve(
    RecipeHttpApp().withDefaultErrorResponse
  )
  .provide(
    Server.defaultWithPort(8080),
    InMemoryRecipeRepo.layer,
    RecipeService.layer
  )

It’s important to note that the ZIO.provide method’s argument list is the application dependency list. To expand a bit more on the dependency injection topic, our server needs a Server that is listening to a port, a RecipeRepository, and a RecipeService.

5. Client

Conveniently enough, we can use the ZIO-HTTP client to consume the REST API we implemented in the above section.

The ZIO-HTTP client provides a fluent API to make HTTP calls using ZIO effects. Let’s create a recipe and then fetch it:

object HttpClient extends ZIOAppDefault:
  val url = "http://localhost:8080/recipes"

  private val program = for {
    postRes <- Client.request(
      url,
      Method.POST,
      content = Body.fromString("""{
          |"id": 1,
          |"name": "burger",
          |"ingredients": ["beef", "salt", "pepper"]
          |}""".stripMargin)
    )
    data <- postRes.body.asString
    _ <- Console.printLine(s"posted: $data")
    getRes <- Client.request(
      s"$url/1",
      Method.GET
    )
    gotData <- getRes.body.asString
    _ <- Console.printLine(s"gotData: $gotData")

  } yield ()

  def run: ZIO[Environment with ZIOAppArgs with Scope, Any, Any] =
    program.provide(Client.default)

6. Middleware

We often need to apply the same functionality blocks in many places or even globally in HTTP applications. Examples include authentication, logging, error handling, CORS (Cross-Origin Resource Sharing) handling, compression, and request/response transformations.

ZIO-HTTP Middleware is a function that surrounds requests before they reach the final handler. In other words, we can implement certain behaviors and transformations to the request, the response, or both.

We’ll showcase two middlewares.

The headerMiddleware adds the header “X-Environment” header to the HTTP request:

val headerMiddleware =
  RequestHandlerMiddlewares.addHeader("X-Environment", "Dev")

The loggingMiddleware logs request and response bodies:

val loggingMiddleware = RequestHandlerMiddlewares.requestLogging(
  logRequestBody = true,
  logResponseBody = true
)

The @@ operator allows us to attach middleware to an application, in our case RecipeHttpApplication:

Server
  .serve(
    (RecipeHttpApp() @@ headerMiddleware @@ loggingMiddleware).withDefaultErrorResponse
  )

7. WebSockets

Since ZIO-HTTP started as a WebSocket-only framework, it would be an oversight if we didn’t discuss its WebSocket support!

The ZIO’s websocket handler is another Partial Function that the Http interface can collect with the collectZIO function, similar to the other handlers we wrote for the REST API above.

Let’s write a websocket application that responds by repeating the message that the client sent twice:

def apply(): Http[Any, Nothing, Request, Response] =

  val socket = Handler.webSocket { channel =>
    channel.receiveAll {
      case ChannelEvent.Read(WebSocketFrame.Text(input)) =>
        channel.send(ChannelEvent.Read(WebSocketFrame.text(input * 2)))
      case _ =>
        ZIO.unit
    }
  }

  Http.collectZIO[Request] {
    case Method.GET -> Root / IngredientWebsocketApp.appContext =>
      socket.toResponse
  }

8. Testing

The ZIO test library is created with flexibility, reusability, and composition in mind. For this reason, tests are first-class values, meaning we can compose, transform, or reuse them.

Another choice that makes the ZIO test library stand out is that the tests are also ZIO effects. As a result, we can solve common test problems like retries or timeouts by simply using ZIO’s functions without the need for additional test toolkits or libraries.

Since we want to test our endpoints on a Netty server, let’s add the zio-http-testkit library:

libraryDependencies += "dev.zio" %% "zio-http-testkit" % "3.0.0-RC2" % Test
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")

With this setup out of the way, it’s time to write some test cases.

First, we need to write the test helper methods that the test cases will use:

private def getRecipe1 =
  for {
    port <- ZIO.serviceWith[Server](_.port)
  } yield Request
    .get(
      URL(Path.root / "recipes" / "1").withPort(port)
    )
    .addHeaders(Headers(Header.Accept(MediaType.text.`plain`)))

private def postRecipe1 =
  for {
    port <- ZIO.serviceWith[Server](_.port)
  } yield Request
    .post(
      Body.fromString("""
          |{
          | "id": 1,
          | "name": "test-recipe",
          | "ingredients": ["ingr1", "ingr2"]
          |}
          |""".stripMargin),
      url = URL(Path.root / "recipes").withPort(port)
    )
    .addHeaders(Headers(Header.Accept(MediaType.text.`plain`)))

Our example verifies that we can fetch a recipe after its creation:

test("post should create recipes") {
  for {
    client <- ZIO.service[Client]
    testRequest1 <- postRecipe1
    _ <- TestServer.addHandler {
      RecipeHttpApp.getHandler
    }
    _ <- TestServer.addHandler {
      RecipeHttpApp.postHandler
    }
    response1 <- client.request(testRequest1)
    testRequest2 <- getRecipe1
    response2 <- client.request(testRequest2)
  } yield assertTrue(
    status(response1) == Status.Ok,
    status(response2) == Status.Ok
  )
}.provideSome[Client with Driver](
  InMemoryRecipeRepo.layer,
  RecipeService.layer,
  TestServer.layer,
  Scope.default
)

A noteworthy observation is that zio-http-testkit allows us to build the API under test according to the needs of a test case. In our example, the API under test consists only of the two endpoints we use. Furthermore, via the functions provideSome and provide that ZIO test provides, we can easily use different dependency lists for each test case if needed.

9. Conclusion

In this article, we got a high-level overview of ZIO-HTTP and learned some of its practical features, such as building REST APIs and websockets. We also looked at some examples that describe how to test code using the zio-http-testkit library.

As always, the code for this article is available over on GitHub.