Introduction to http4s

Table of Contents

Encoders and Decoders in Circe

In the context of JSON handling with Circe, an Encoder[A] provides a way to convert a value of type A into a JSON representation. Conversely, a Decoder[A] takes a JSON value and attempts to produce a value of type A, returning either a successfully decoded value or an error.

Circe includes a wide range of built-in implicit instances of these type classes for common types in the Scala standard library — including Int, String, Boolean, and more. This means you often get encoding and decoding "for free" without having to write custom logic.

These type classes play a foundational role when building JSON-based APIs with http4s and Cats, enabling automatic conversion between domain models and HTTP request/response bodies.

To enable this automatic derivation, make sure to include the following imports:

1import io.circe._
2import io.circe.generic.auto._
3import io.circe.syntax._
4import org.http4s.circe._

With these imports in place, http4s will be able to automatically encode your case classes into JSON responses and decode JSON request bodies into domain models using Circe.

EntityEncoder and EntityDecoder

In Scala, particularly when using the http4s library, anEntityEncoder[A] is a type class that defines how to serialize a value of type Ainto an HTTP response body.

It specifies:

• The serialized content (e.g., JSON, plain text, HTML)

• The appropriate Content-Type header

• The streaming mechanism for sending the response body

Likewise, EntityDecoder[A] is a type class that defines how to parse the HTTP request body and decode it into a Scala type A.

With Circe and http4s, both encoding and decoding JSON can be handled automatically. Here's an example that shows both sending and receiving a User object:

1import cats.effect._
2import io.circe.generic.auto._
3import io.circe.syntax._
4import org.http4s._
5import org.http4s.dsl.Http4sDsl
6import org.http4s.circe._
7import org.http4s.implicits._
8import org.http4s.server.blaze._
9
10case class User(name: String, age: Int)
11
12object UserService extends IOApp with Http4sDsl[IO] {
13
14  // These are provided implicitly by http4s-circe integration
15  implicit val userDecoder: EntityDecoder[IO, User] = jsonOf[IO, User]
16  implicit val userEncoder: EntityEncoder[IO, User] = jsonEncoderOf[IO, User]
17
18  val routes = HttpRoutes.of[IO] {
19    case GET -> Root / "user" =>
20      val user = User("Alice", 30)
21      Ok(user) // http4s uses EntityEncoder to serialize as JSON
22
23    case req @ POST -> Root / "user" =>
24      for {
25        user <- req.as[User] // http4s uses EntityDecoder to parse the JSON body
26        resp <- Ok(s"Received user: ${user.name}, age ${user.age}")
27      } yield resp
28  }
29
30  override def run(args: List[String]): IO[ExitCode] = {
31    val httpApp = routes.orNotFound
32    BlazeServerBuilder[IO]
33      .bindHttp(8080, "localhost")
34      .withHttpApp(httpApp)
35      .serve
36      .compile
37      .drain
38      .as(ExitCode.Success)
39  }
40}

In this example, the GET /user endpoint returns a JSON response, andPOST /user accepts a JSON payload and deserializes it into a User instance.

The key imports that make this work include:

1import io.circe.generic.auto._         // Automatic generator of Encoder/Decoder
2import io.circe.syntax._               // .asJson extension
3import org.http4s.circe._              // http4s <-> Circe integratio
4import cats.implicits.*                // required for req.as[Director]
5import org.http4s.syntax.kleisli.*     // Used for router.orNotFound

With these type classes and imports in place, http4s can seamlessly handle JSON serialization and deserialization, making it easy to work with strongly typed domain models in your HTTP APIs.

QueryParameters Decoder

In http4s, query parameters from the URL can be extracted and parsed usingQueryParamDecoder and its related matchers. This makes it easy to work with typed query params in a composable and safe way.

QueryParamDecoderMatcher is used for required query parameters.

OptionalValidatingQueryParamDecoderMatcher is used for optional query parameters that may also require validation.

• You can also define a custom QueryParamDecoderto parse domain-specific types from strings.

Here's a complete example demonstrating all three:

1import cats.data.Validated
2import cats.effect._
3import org.http4s._
4import org.http4s.dsl.Http4sDsl
5import org.http4s.implicits._
6import org.http4s.server.blaze._
7import org.http4s.circe._
8import org.http4s.dsl.impl._
9
10import org.http4s.QueryParamDecoder
11import cats.syntax.all._
12
13case class ZipCode(value: String)
14
15implicit val zipCodeQueryParamDecoder: QueryParamDecoder[ZipCode] =
16  QueryParamDecoder[String].emap { str => // emap returns an Either
17    if (str.matches("\\d{5}")) Right(ZipCode(str)) // if it matches 5 digits
18    else Left(ParseFailure("Invalid ZipCode", s"${str} is not a valid zip code"))
19  }
20
21object CityMatcher extends QueryParamDecoderMatcher[String]("city")
22object LimitMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("limit")
23object ZipMatcher extends QueryParamDecoderMatcher[ZipCode]("zip")
24
25object QueryParamService extends IOApp with Http4sDsl[IO] {
26
27  val routes = HttpRoutes.of[IO] {
28    case GET -> Root / "search" :? CityMatcher(city) +& LimitMatcher(limit) +& ZipMatcher(zip) =>
29      val limitMsg = limit match {
30        case None => "no limit provided"
31        case Some(validationResult) => validationResult match {
32          case Valid(n)   => s"limit=$n"
33          case Invalid(_) => "invalid limit"
34        }
35      }
36
37      // Alternative with fold:
38      // val limitMsg = limit.fold("no limit provided")(_.fold(_ => "invalid limit", n => s"limit=$n"))
39      // Type of limit is Option[ValidatedNel[ParseFailure, Int]]
40      // First fold handles the Option, if limit is None, return "no limit provided"
41      // Second fold handles the Validated
42
43      Ok(s"Searching for city=${city}, zip=${zip.value}, ${limitMsg}")
44  }
45
46  override def run(args: List[String]): IO[ExitCode] = {
47    BlazeServerBuilder[IO]
48      .bindHttp(8080, "localhost")
49      .withHttpApp(routes.orNotFound)
50      .serve
51      .compile
52      .drain
53      .as(ExitCode.Success)
54  }
55}

In this example:

?city= is required — missing it will result in a 400.

?limit= is optional — if present, it must be a valid integer.

?zip= uses a custom type ZipCode with validation logic.

These matchers give you full control over query parameter handling, including defaulting, validation, and mapping to domain types — all in a typesafe and composable way.

1object YearQueryParamMatcher extends OptionalValidatingQueryParamDecoderMatcher[Year]("year")
2
3implicit val yearQueryParamDecoder: QueryParamDecoder[Year] =
4  QueryParamDecoder[Int].emap { y =>
5    Try(Year.of(y)) // Try represents a computation that may either result in an exception or a successful value
6      .toEither
7      .leftMap { tr =>
8        ParseFailure(tr.getMessage, tr.getMessage)
9      }
10  }

The emap method returns an Either, where the leftMap transforms any thrown exceptions into ParseFailure instances. ParseFailure is an http4s-specific type representing errors encountered while parsing HTTP messages.

A QueryParamDecoder[A] operates by returning an Either[ParseFailure, A], indicating success or failure of decoding a single query parameter value. The OptionalValidatingQueryParamDecoderMatcher then automatically lifts this Either into a ValidatedNel[ParseFailure, A] wrapped in an Option. This means:

If the parameter is missing, you get None.

If the parameter is present and decoding succeeds, you get Some(Valid(year)).

If the parameter is present but decoding fails, you get Some(Invalid(...)).

In Cats, Validated is a data type representing either a success (Valid[A]) or failure (Invalid[E]) case. Unlike Either, Validated is designed to accumulate errors, which makes it particularly useful for validating multiple fields in forms or query parameters.

Extracting Path Parameters in http4s

In http4s, you can define custom extractors using Scala's unapply method to parse complex path segments into your domain types.

Suppose you want to extract a director's full name from a path segment like Quentin Tarantino and parse it into a Director case class.

This extractor splits the string on spaces and expects exactly two parts: first name and last name.

If the URL path matches /director/Quentin Tarantino, the DirectorVar extractor parses the string into a Director object accessible in your route handler.

1import scala.util.Try
2import cats.effect._
3import org.http4s._
4import org.http4s.dsl.io._
5import org.http4s.server.blaze._
6
7case class Director(firstName: String, lastName: String)
8
9object DirectorVar {
10  def unapply(str: String): Option[Director] = {
11    if (str.nonEmpty) {
12      Try {
13        val parts = str.split(" ")
14        if (parts.length == 2) Director(parts(0), parts(1))
15        else throw new IllegalArgumentException("Invalid director format")
16      }.toOption
17    } else None
18  }
19}
20
21val routes = HttpRoutes.of[IO] {
22  case GET -> Root / "director" / DirectorVar(director) =>
23    Ok(s"Director's first name: ${director.firstName}, last name: ${director.lastName}")
24}

If the string cannot be parsed correctly (e.g., only one name or more than two words), the route will not match and http4s will return a 404 Not Found.