Skip to main content
zio-cats

HTTP Clients and Servers

Step 14 of 15

HTTP Clients and Servers

Production applications need HTTP for APIs, webhooks, and microservices. ZIO provides a dedicated HTTP library, while Cats Effect uses the http4s ecosystem.

The HTTP Problem

We need:

  1. Type-safe routing — Catch path/query parameter errors at compile time
  2. Streaming support — Handle large responses without memory issues
  3. Concurrent requests — Make multiple HTTP calls efficiently
  4. Middleware — Add auth, logging, CORS consistently

HTTP Servers: Basic Routes

Both libraries use pattern matching for route definitions.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.dsl._
import org.http4s.implicits._
import org.http4s.server.blaze.BlazeServerBuilder

object App extends IOApp.Simple {
  val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
    case GET -> Root / "hello" / name =>
      Ok(s"Hello, $name!")

    case POST -> Root / "users" =>
      Created("Created")
  }

  val run: IO[Unit] =
    BlazeServerBuilder[IO]
      .bindHttp(8080)
      .withHttpApp(routes.orNotFound)
      .resource
      .use(_ => IO.never)
}

HttpRoutes.of with pattern matching

ZIO
import zio.http._
import zio._

object App extends ZIOAppDefault {
  val app: HttpApp[Any] = Http.collect[Request] {
    case Method.GET -> Root / "hello" / name =>
      Response.text(s"Hello, $name!")

    case Method.POST -> Root / "users" =>
      Response.text("Created")

    case _ =>
      Response.status(Status.NotFound)
  }

  def run =
    Server.serve(app)
}

Http.collect with pattern matching

TIP:

ZIO HTTP uses HttpApp[Any] for a complete application. http4s uses HttpRoutes[IO] (partial) combined with .orNotFound to become a full HttpApp[IO].

Path and Query Parameters

Both libraries extract path segments and query parameters with type safety.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.dsl._
import org.http4s.implicits._

val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
  // Path variable: /users/123
  case GET -> Root / "users" / IntVar(userId) =>
    Ok(s"User ID: $userId")

  // Query params: /search?q=zio&limit=10
  case GET -> Root / "search" :? Q(q, limit) =>
    Ok(s"Search: $q, Limit: $limit")

  // String path var: /posts/foo/comments/bar
  case GET -> Root / "posts" / postId / "comments" / commentId =>
    Ok(s"Post $postId, Comment $commentId")
}

object Q {
  def unapply(params: Map[String, Seq[String]]): Option[(String, Int)] =
    for {
      q <- params.get("q").flatMap(_.headOption)
      limit <- params.get("limit").flatMap(_.headOption.flatMap(_.toIntOption))
    } yield (q, limit)
}

IntVar, LongVar for typed extraction

ZIO
import zio.http._
import zio._

val app: HttpApp[Any] = Http.collect[Request] {
  // Path parameter: /users/123
  case Method.GET -> Root / "users" / id(userId) =>
    Response.text(s"User ID: $userId")

  // Query parameter: /search?q=zio
  case Method.GET -> Root / "search" =>
    val query = "zio" // Would come from request.url.queryParams
    Response.text(s"Search: $query")

  // Multiple path params: /posts/123/comments/456
  case Method.GET -> Root / "posts" / postId / "comments" / commentId =>
    Response.text(s"Post $postId, Comment $commentId")
}

id(path) extracts typed values

Request Bodies

Handle JSON, form data, and raw request bodies.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.dsl._
import org.http4s.circe._
import io.circe.generic.auto._

case class User(name: String, email: String)

val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
  case req @ POST -> Root / "users" =>
    for {
      user <- req.as[User]
      response <- Ok(user)
    } yield response

  case req @ POST -> Root / "plain" =>
    for {
      text <- req.as[String]
      response <- Ok(text)
    } yield response
}

http4s-circe for JSON handling

ZIO
import zio.http._
import zio.json._
import zio._

case class User(name: String, email: String)

object User {
  implicit val codec: JsonCodec[User] =
    DeriveJsonCodec.gen[User]
}

val app: HttpApp[Any] = Http.collect[Request] {
  case req @ Method.POST -> Root / "users" =>
    for {
      body <- req.body.asJson[User]
      response = Response.json(body)
    } yield response

  case req @ Method.POST -> Root / "plain" =>
    for {
      text <- req.body.asString
      response = Response.text(text)
    } yield response
}

zio-http includes zio-json integration

TIP:

ZIO HTTP includes zio-json codecs. http4s requires separate modules: http4s-circe for JSON, http4s-jawn as fallback.

HTTP Clients

Make outgoing HTTP requests to external APIs.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.client.blaze.BlazeClient
import org.http4s.implicits._

object ClientApp extends IOApp.Simple {
  val uri = uri"https://api.github.com/repos/zio/zio"

  val run: IO[Unit] =
    BlazeClient[IO].use { client =>
      for {
        response <- client.expect[String](uri)
        _ <- IO.println(s"Response: $response")
      } yield ()
    }
}

expect[T] decodes response

ZIO
import zio.http._
import zio._

object ClientApp extends ZIOAppDefault {
  val url = "https://api.github.com/repos/zio/zio"

  def run =
    for {
      response <- Client.request(url)
      data <- response.body.asString
      _ <- Console.printLine(s"Stars: $data")
    } yield ()
}

Client.request returns ZIO

Request Configuration

Configure headers, timeouts, and authentication.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.client._
import org.http4s.implicits._

val request: Request[IO] =
  Request[IO](
    method = Method.GET,
    uri = uri"https://api.example.com/data"
  ).putHeaders(
    Header("Authorization", "Bearer token"),
    Header.Accept("application/json")
  )

val run: IO[String] =
    BlazeClient[IO].use { client =>
      for {
        response <- client.fetch[String](request) { resp =>
          resp.as[String]
        }
      } yield response
    }

Request() with putHeaders

ZIO
import zio.http._
import zio._

val request: Request =
  Request.get(
    url = "https://api.example.com/data",
    headers = Headers(
      Header.Authorization("Bearer token"),
      Header.Accept("application/json")
    )
  )

// Or with builder
val customRequest =
  Request
    .get("https://api.example.com/data")
    .addHeader(Header.UserAgent("MyApp/1.0"))

def run =
  for {
    response <- Client.request(request)
    body <- response.body.asString
  } yield body

Request builder pattern

Error Handling

Handle network failures, timeouts, and HTTP errors.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.client._
import scala.concurrent.duration._

val request =
  BlazeClient[IO].use { client =>
    client
      .expect[String](uri"https://api.example.com/data")
      .timeout(5.seconds)
      .handleErrorWith {
        case _: java.util.concurrent.TimeoutException =>
          IO.pure("Request timed out")

        case _: IOException =>
          IO.pure("Network error")
      }
  }

// Use expectOr to handle HTTP errors
val checked =
  BlazeClient[IO].use { client =>
    client.expectOr[String](uri"https://api.example.com/data") {
      case response =>
        IO.raiseError(
          new Exception(s"HTTP ${response.status.code}")
        )
    }
  }

expectOr for error handling

ZIO
import zio.http._
import zio._

val request = Client
  .request("https://api.example.com/data")
  .timeout(5.seconds)
  .catchSome {
    case _: TimeoutException =>
      ZIO.debug("Request timed out").as(
        Response.status(Status.GatewayTimeout)
      )

    case _: IOException =>
      ZIO.debug("Network error").as(
        Response.status(Status.BadGateway)
      )
  }

// Check HTTP status
val checked =
  for {
    response <- Client.request(url)
    _ <- ZIO.cond(
      response.status.isSuccess,
      (),
      "HTTP error: " + response.status
    )
    body <- response.body.asString
  } yield body

ZIO.catchSome for error recovery

WARNING:

HTTP clients can fail for many reasons: network errors, timeouts, DNS failures. Always handle these cases explicitly in production code.

Middleware

Add cross-cutting concerns: auth, logging, CORS.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.server.middleware._
import org.http4s.server.middleware.authentication._

// Logging middleware
val withLogging =
  Logger.httpApp(logHeaders = true, logBody = false)(routes)

// CORS middleware
val withCors =
  CORS(routes.withHttpExecutionContext(ec))

// Auth middleware
val withAuth =
  AuthedRoutes[Long, IO] {
    case GET -> Root / "admin" as userId =>
      Ok(s"User $userId")
  }

val app: HttpApp[IO] =
  withLogging
    .withCorsOrNotFound
    .withBasicAuth("admin", "password")

Middleware functions from org.http4s.server.middleware

ZIO
import zio.http._
import zio._

// Logging middleware
val logging: HttpMiddleware[Any] =
  Handler.fromFunctionZIO[Request] { request =>
    for {
      _ <- ZIO.debug(s"${request.method} ${request.url}")
      response <- Handler.identity
    } yield response
  }

// Auth middleware
val auth: HttpMiddleware[Any] =
  Handler.fromFunctionZIO[Request] { request =>
    val token = request.headers.getHeader("Authorization")
    token match {
      case Some(_) =>
        Handler.identity
      case None =>
        Handler.response(Response.unauthorized("Missing token"))
    }
  }

// Apply middleware
val app: HttpApp[Any] =
  Http.collect[Request] {
    case Method.GET -> Root / "admin" =>
      Response.text("Admin panel")
  } @@ logging @@ auth

@@ operator to chain middleware

Streaming Responses

Handle large responses without loading into memory.

Cats Effect
import cats.effect._
import org.http4s._
import org.http4s.dsl._
import org.http4s.implicits._
import fs2._

val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
  case GET -> Root / "stream" =>
    // Stream from data source
    val data: Stream[IO, Byte] =
      Stream(
        "line1\n",
        "line2\n",
        "line3\n"
      ).through(fs2.text.utf8.encode)

    Ok(data)

  // Server-sent events
  case GET -> Root / "events" =>
    val events =
      Stream
        .awakeEvery[IO](1.second)
        .map(_ => s"data: ping\n\n")
        .through(fs2.text.utf8.encode)

    Ok(events)
      .map(_.withContentType(
        MediaType.text.eventstream
      ))
}

fs2.Stream for responses

ZIO
import zio.http._
import zio._
import zio.stream._

val app: HttpApp[Any] = Http.collect[Request] {
  case Method.GET -> Root / "stream" =>
    // Stream from a data source
    val data: ZStream[Any, Nothing, Byte] =
      ZStream.fromIterable(
        List("line1\n", "line2\n", "line3\n")
      ).mapChunks(_.flatMap(_.getBytes))

    Response(
      status = Status.Ok,
      body = Body.fromStream(data)
    )

  // Server-sent events
  case Method.GET -> Root / "events" =>
    val events =
      ZStream
        .tick(1.second)
        .map(_ => s"data: ping\n\n")
        .mapChunks(_.flatMap(_.getBytes))

    Response(
      status = Status.Ok,
      headers = Headers(
        Header.ContentType("text/event-stream")
      ),
      body = Body.fromStream(events)
    )
}

Body.fromStream for streaming

Server Configuration

Configure ports, threads, and server behavior.

Cats Effect
import cats.effect._
import org.http4s.server.blaze.BlazeServerBuilder
import org.http4s.implicits._
import scala.concurrent.ExecutionContext.global

object MainApp extends IOApp.Simple {
  val run: IO[Unit] =
    BlazeServerBuilder[IO](global)
      .bindHttp(8080, "0.0.0.0")
      .withHttpApp(routes.orNotFound)
      .withoutBanner
      .withIdleTimeout(60.seconds)
      .withResponseHeaderTimeout(30.seconds)
      .resource
      .use(_ => IO.never)
}

BlazeServerBuilder for configuration

ZIO
import zio.http._
import zio._

object MainApp extends ZIOAppDefault {
  val app: HttpApp[Any] = Http.collect[Request] {
    case _ => Response.text("Hello!")
  }

  def run =
    Server.serve(
      app,
      Server.Config.default.port(8080)
    ).provide(
      Server.live,
      Scope.default
    )

  // Custom config
  val customConfig =
    Server.Config.default
      .port(8443)
      .ssl(
        Server.SSLConfig
          .FromCertFile(
            certPath = "/path/to/cert.pem",
            keyPath = "/path/to/key.pem"
          )
      )
}

Server.serve with ZLayer configuration

Key Differences

FeatureZIO HTTPhttp4s
Dependencydev.zio:zio-httporg.http4s:http4s-blaze-server
ServerBuilt-in NettyBlaze (default), Ember
ClientBuilt-inhttp4s-blaze-client, Ember
JSONzio-json (included)http4s-circe (separate)
StreamingZStreamfs2.Stream
Middleware@@ operatorMiddleware functions
RoutesHttp.collect[Request]HttpRoutes.of[IO]
Status codesStatus.Ok, Status.NotFoundOk, NotFound (DSL)
TIP:

ZIO HTTP is a batteries-included solution with server, client, and JSON. http4s is modular—pick your server backend (Blaze, Ember), JSON library (circe, jawn), and client separately.

Next Steps

You've learned HTTP clients and servers. Next: Database access.

Next: Database Access →