Skip to content

Commit

Permalink
Documentation of BinaryCodecs (#2793)
Browse files Browse the repository at this point in the history
* add to sidebar.

* binary codecs for request and response bodies.

* trivial fix.

* revert faq.

* fmt.

* trivial code change.

* use :+ instead of appended
  • Loading branch information
khajavi authored Apr 23, 2024
1 parent 0f391cf commit ca2937d
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 7 deletions.
17 changes: 10 additions & 7 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,14 @@ lazy val zioHttpExample = (project in file("zio-http-example"))
.settings(stdSettings("zio-http-example"))
.settings(publishSetting(false))
.settings(runSettings(Debug.Main))
.settings(libraryDependencies ++= Seq(`jwt-core`))
.settings(libraryDependencies ++= Seq(`jwt-core`, `zio-schema-json`))
.settings(
libraryDependencies ++= Seq(
"dev.zio" %% "zio-config" % "4.0.1",
"dev.zio" %% "zio-config-typesafe" % "4.0.1",
"dev.zio" %% "zio-metrics-connectors" % "2.3.1",
"dev.zio" %% "zio-metrics-connectors-prometheus" % "2.3.1"
)
libraryDependencies ++= Seq(
"dev.zio" %% "zio-config" % "4.0.1",
"dev.zio" %% "zio-config-typesafe" % "4.0.1",
"dev.zio" %% "zio-metrics-connectors" % "2.3.1",
"dev.zio" %% "zio-metrics-connectors-prometheus" % "2.3.1",
),
)
.dependsOn(zioHttpJVM, zioHttpCli, zioHttpGen)

Expand Down Expand Up @@ -326,6 +326,9 @@ lazy val docs = project
"dev.zio" %% "zio-config" % "4.0.1",
),
publish / skip := true,
mdocVariables ++= Map(
"ZIO_SCHEMA_VERSION" -> ZioSchemaVersion
)
)
.dependsOn(zioHttpJVM)
.enablePlugins(WebsitePlugin)
Expand Down
116 changes: 116 additions & 0 deletions docs/binary_codecs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
id: binary_codecs
title: BinaryCodecs for Request/Response Bodies
sidebar_label: BinaryCodecs
---

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](https://zio.dev/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 type `A` using the implicit `BinaryCodec` for `A`.
* **`Body.from[A]`** — It encodes custom data of type `A` to a response body using the implicit `BinaryCodec` for `A`.

```scala
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:

```scala mdoc:silent
case class Book(title: String, authors: List[String])
```

To create a `BinaryCodec[Book]` for our `Book` case class, we can implement the `BinaryCodec` interface:

```scala mdoc:compile-only
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:

```scala mdoc:compile-only
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:

```scala
libraryDependencies += "dev.zio" %% "zio-schema-json" % "@ZIO_SCHEMA_VERSION@"
libraryDependencies += "dev.zio" %% "zio-schema-protobuf" % "@ZIO_SCHEMA_VERSION@"
libraryDependencies += "dev.zio" %% "zio-schema-avro" % "@ZIO_SCHEMA_VERSION@"
libraryDependencies += "dev.zio" %% "zio-schema-thrift" % "@ZIO_SCHEMA_VERSION@"
```

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:

```scala mdoc:passthrough
import utils._

printSource("zio-http-example/src/main/scala/example/codecs/ResponseBodyJsonSerializationExample.scala")
```

### 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:

```scala mdoc:passthrough
import utils._

printSource("zio-http-example/src/main/scala/example/codecs/RequestBodyJsonDeserializationExample.scala")
```

To send a POST request to the `/books` endpoint with a JSON body containing a `Book` object, we can use the following `curl` command:

```shell
$ 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:

```shell
$ curl http://localhost:8080/books
```
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const sidebars = {
"dsl/client"
]
},
"binary_codecs",
"testing-http-apps",
{
type: "category",
Expand Down
3 changes: 3 additions & 0 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ object Dependencies {
val `zio-schema` = "dev.zio" %% "zio-schema" % ZioSchemaVersion
val `zio-schema-json` = "dev.zio" %% "zio-schema-json" % ZioSchemaVersion
val `zio-schema-protobuf` = "dev.zio" %% "zio-schema-protobuf" % ZioSchemaVersion
val `zio-schema-avro` = "dev.zio" %% "zio-schema-avro" % ZioSchemaVersion
val `zio-schema-thrift` = "dev.zio" %% "zio-schema-thrift" % ZioSchemaVersion
val `zio-schema-msg-pack` = "dev.zio" %% "zio-schema-msg-pack" % ZioSchemaVersion
val `zio-test` = "dev.zio" %% "zio-test" % ZioVersion % "test"
val `zio-test-sbt` = "dev.zio" %% "zio-test-sbt" % ZioVersion % "test"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 app: 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(app.toHttpApp).provide(Server.default, ZLayer.fromZIO(Ref.make(List.empty[Book])))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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 app: Routes[Any, Nothing] =
Routes(
Method.GET / "users" ->
handler(Response(body = Body.from(List(book1, book2, book3)))),
)

def run = Server.serve(app.toHttpApp).provide(Server.default)
}

0 comments on commit ca2937d

Please sign in to comment.