BinaryCodecs for Request/Response Bodies
ZIO HTTP has built-in support for encoding and decoding request/response bodies. This is achieved using generating codecs for our custom data types powered by ZIO Schema.
ZIO Schema is a library for defining the schema for any custom data type, including case classes, sealed traits, and enumerations, other than the built-in types. It provides a way to derive codecs for these custom data types, for encoding and decoding data to/from JSON, Protobuf, Avro, and other formats.
Having codecs for our custom data types allows us to easily serialize/deserialize data to/from request/response bodies in our HTTP applications.
The Body data type in ZIO HTTP represents the body message of a request or a response. It has two main functionality for encoding and decoding request/response bodies, both of which require an implicit BinaryCodec for the corresponding data type:
Body#to[A]— It decodes the request body to a custom data of typeAusing the implicitBinaryCodecforA.Body.from[A]— It encodes custom data of typeAto a response body using the implicitBinaryCodecforA.
trait Body {
def to[A](implicit codec: BinaryCodec[A]): Task[A] = ???
}
object Body {
def from[A](a: A)(implicit codec: BinaryCodec[A]): Body = ???
}
To use these two methods, we need to have an implicit BinaryCodec for our custom data type, A. Let's assume we have a Book case class with title, authors fields:
case class Book(title: String, authors: List[String])
To create a BinaryCodec[Book] for our Book case class, we can implement the BinaryCodec interface:
import zio._
import zio.stream._
import zio.schema.codec._
implicit val bookBinaryCodec = new BinaryCodec[Book] {
override def encode(value: Book): Chunk[Byte] = ???
override def streamEncoder: ZPipeline[Any, Nothing, Book, Byte] = ???
override def decode(whole: Chunk[Byte]): Either[DecodeError, Book] = ???
override def streamDecoder: ZPipeline[Any, DecodeError, Byte, Book] = ???
}
Now, when we call Body.from(Book("Zionomicon", List("John De Goes"))), it will encode the Book case class to a response body using the implicit BinaryCodec[Book]. But, what happens if we add a new field to the Book case class, or change one of the existing fields? We would need to update the BinaryCodec[Book] implementation to reflect these changes. Also, if we want to support body response bodies with multiple book objects, we would need to implement a new codec for List[Book]. So, maintaining these codecs can be cumbersome and error-prone.
ZIO Schema simplifies this process by providing a way to derive codecs for our custom data types. For each custom data type, A, if we write/derive a Schema[A] using ZIO Schema, then we can derive a BinaryCodec[A] for any format supported by ZIO Schema, including JSON, Protobuf, Avro, and Thrift.
So, let's generate a Schema[Book] for our Book case class:
import zio.schema._
object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen[Book]
}
Based on what format we want, we can add one of the following codecs to our build.sbt file:
libraryDependencies += "dev.zio" %% "zio-schema-json" % "1.7.5"
libraryDependencies += "dev.zio" %% "zio-schema-protobuf" % "1.7.5"
libraryDependencies += "dev.zio" %% "zio-schema-avro" % "1.7.5"
libraryDependencies += "dev.zio" %% "zio-schema-thrift" % "1.7.5"
After adding the required codec's dependency, we can import the right binary codec inside the zio.schema.codec package:
| Codecs | Schema Based BinaryCodec (zio.schema.codec package) | Output |
|---|---|---|
| JSON | JsonCodec.schemaBasedBinaryCodec[A](implicit schema: Schema[A]) | BinaryCodec[A] |
| Protobuf | ProtobufCodec.protobufCodec[A](implicit schema: Schema[A]) | BinaryCodec[A] |
| Avro | AvroCodec.schemaBasedBinaryCodec[A](implicit schema: Schema[A]) | BinaryCodec[A] |
| Thrift | ThriftCodec.thriftBinaryCodec[A](implicit schema: Schema[A]) | BinaryCodec[A] |
| MsgPack | MessagePackCodec.messagePackCodec[A](implicit schema: Schema[A]) | BinaryCodec[A] |
That is very simple! To have a BinaryCodec of type A we only need to derive a Schema[A] and then use an appropriate codec from the zio.schema.codec package.
JSON Codec Example
JSON Serialization of Response Body
Assume want to write an HTTP API that returns a list of books in JSON format:
//> using dep "dev.zio::zio-http:3.4.0"
//> using dep "dev.zio::zio-schema:1.7.2"
//> using dep "dev.zio::zio-schema-json:1.7.2"
//> using dep "dev.zio::zio-schema-derivation:1.7.5"
package example.codecs
import zio._
import zio.schema.codec.JsonCodec.schemaBasedBinaryCodec
import zio.schema.{DeriveSchema, Schema}
import zio.http._
object ResponseBodyJsonSerializationExample extends ZIOAppDefault {
case class Book(title: String, authors: List[String])
object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen
}
val book1 = Book("Programming in Scala", List("Martin Odersky", "Lex Spoon", "Bill Venners", "Frank Sommers"))
val book2 = Book("Zionomicon", List("John A. De Goes", "Adam Fraser"))
val book3 = Book("Effect-Oriented Programming", List("Bill Frasure", "Bruce Eckel", "James Ward"))
val routes: Routes[Any, Nothing] =
Routes(
Method.GET / "users" ->
handler(Response(body = Body.from(List(book1, book2, book3)))),
)
def run = Server.serve(routes).provide(Server.default)
}
JSON Deserialization of Request Body
In the example below, we have an HTTP API that accepts a JSON request body containing a Book object and adds it to a list of books:
//> using dep "dev.zio::zio-http:3.4.0"
//> using dep "dev.zio::zio-schema:1.7.2"
//> using dep "dev.zio::zio-schema-json:1.7.2"
//> using dep "dev.zio::zio-schema-derivation:1.7.5"
package example.codecs
import zio._
import zio.schema.codec.JsonCodec.schemaBasedBinaryCodec
import zio.schema.{DeriveSchema, Schema}
import zio.http._
object RequestBodyJsonDeserializationExample extends ZIOAppDefault {
case class Book(title: String, authors: List[String])
object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen
}
val routes: Routes[Ref[List[Book]], Nothing] =
Routes(
Method.POST / "books" ->
handler { (req: Request) =>
for {
book <- req.body.to[Book].catchAll(_ => ZIO.fail(Response.badRequest("unable to deserialize the request")))
books <- ZIO.service[Ref[List[Book]]]
_ <- books.updateAndGet(_ :+ book)
} yield Response.ok
},
Method.GET / "books" ->
handler { (_: Request) =>
ZIO
.serviceWithZIO[Ref[List[Book]]](_.get)
.map(books => Response(body = Body.from(books)))
},
)
def run = Server.serve(routes).provide(Server.default, ZLayer.fromZIO(Ref.make(List.empty[Book])))
}
To send a POST request to the /books endpoint with a JSON body containing a Book object, we can use the following curl command:
$ curl -X POST -d '{"title": "Zionomicon", "authors": ["John De Goes", "Adam Fraser"]}' http://localhost:8080/books
After sending the POST request, we can retrieve the list of books by sending a GET request to the /books endpoint:
$ curl http://localhost:8080/books