From ca2937de903ad5e8c535889b8443a22fa2de2e22 Mon Sep 17 00:00:00 2001 From: Milad Khajavi Date: Wed, 24 Apr 2024 00:36:12 +0330 Subject: [PATCH] Documentation of BinaryCodecs (#2793) * add to sidebar. * binary codecs for request and response bodies. * trivial fix. * revert faq. * fmt. * trivial code change. * use :+ instead of appended --- build.sbt | 17 +-- docs/binary_codecs.md | 116 ++++++++++++++++++ docs/sidebars.js | 1 + project/Dependencies.scala | 3 + ...equestBodyJsonDeserializationExample.scala | 37 ++++++ ...ResponseBodyJsonSerializationExample.scala | 29 +++++ 6 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 docs/binary_codecs.md create mode 100644 zio-http-example/src/main/scala/example/codecs/RequestBodyJsonDeserializationExample.scala create mode 100644 zio-http-example/src/main/scala/example/codecs/ResponseBodyJsonSerializationExample.scala diff --git a/build.sbt b/build.sbt index e30e75fddd..599cfb0793 100644 --- a/build.sbt +++ b/build.sbt @@ -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) @@ -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) diff --git a/docs/binary_codecs.md b/docs/binary_codecs.md new file mode 100644 index 0000000000..aa2e6bca36 --- /dev/null +++ b/docs/binary_codecs.md @@ -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 +``` diff --git a/docs/sidebars.js b/docs/sidebars.js index 216996ecd9..c593ad078c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -43,6 +43,7 @@ const sidebars = { "dsl/client" ] }, + "binary_codecs", "testing-http-apps", { type: "category", diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 8995c00ac0..b35192e535 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -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" diff --git a/zio-http-example/src/main/scala/example/codecs/RequestBodyJsonDeserializationExample.scala b/zio-http-example/src/main/scala/example/codecs/RequestBodyJsonDeserializationExample.scala new file mode 100644 index 0000000000..ef5235c676 --- /dev/null +++ b/zio-http-example/src/main/scala/example/codecs/RequestBodyJsonDeserializationExample.scala @@ -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]))) +} diff --git a/zio-http-example/src/main/scala/example/codecs/ResponseBodyJsonSerializationExample.scala b/zio-http-example/src/main/scala/example/codecs/ResponseBodyJsonSerializationExample.scala new file mode 100644 index 0000000000..d42edffa87 --- /dev/null +++ b/zio-http-example/src/main/scala/example/codecs/ResponseBodyJsonSerializationExample.scala @@ -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) +}