From d14be1e3293cc0981f1c3ae618528e33913e9c8c Mon Sep 17 00:00:00 2001 From: Milad Khajavi Date: Thu, 16 May 2024 18:02:10 +0330 Subject: [PATCH] Documentation of HttpCodecs (#2824) * initial work. * rename. * add usage section. * create a separate section for declarative endpoints. * fmt. --------- Co-authored-by: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> --- docs/reference/endpoint.md | 4 +- docs/reference/http-codec.md | 331 ++++++++++++++++++ docs/sidebars.js | 77 ++-- .../DeclarativeProgrammingExample.scala} | 27 +- .../style/ImperativeProgrammingExample.scala | 38 ++ 5 files changed, 423 insertions(+), 54 deletions(-) create mode 100644 docs/reference/http-codec.md rename zio-http-example/src/main/scala/example/endpoint/{EndpointWithError.scala => style/DeclarativeProgrammingExample.scala} (53%) create mode 100644 zio-http-example/src/main/scala/example/endpoint/style/ImperativeProgrammingExample.scala diff --git a/docs/reference/endpoint.md b/docs/reference/endpoint.md index c740b0e151..22a6bf5f27 100644 --- a/docs/reference/endpoint.md +++ b/docs/reference/endpoint.md @@ -283,10 +283,10 @@ For failure outputs, we can describe the output properties using the `Endpoint#o ```scala mdoc:passthrough import utils._ -printSource("zio-http-example/src/main/scala/example/endpoint/EndpointWithError.scala") +printSource("zio-http-example/src/main/scala/example/endpoint/style/DeclarativeProgrammingExample.scala") ``` -In the above example, we defined an endpoint that describes a path parameter `id` as input and returns a `Book` as output. If the book is not found, the endpoint returns a `NotFound` status code with a custom error message. +In the above example, we defined an endpoint that describes a query parameter `id` as input and returns a `Book` as output. If the book is not found, the endpoint returns a `NotFound` status code with a custom error message. ### Multiple Failure Outputs Using `Endpoint#outError` diff --git a/docs/reference/http-codec.md b/docs/reference/http-codec.md new file mode 100644 index 0000000000..e86f56e188 --- /dev/null +++ b/docs/reference/http-codec.md @@ -0,0 +1,331 @@ +--- +id: http-codec +title: "HttpCodec" +--- + +In ZIO HTTP when we work with HTTP requests and responses, we are not dealing with raw bytes but with structured data. This structured data is represented by the `Request` and `Response` types. But under the hood, these types are serialized and deserialized to and from raw bytes. This process is handled by HTTP Codecs. We can think of `HttpCodec` as a pair of functions both for encoding and decoding requests and responses: + +```scala +sealed trait HttpCodec[-AtomTypes, Value] { + final def decodeRequest(request: Request)(implicit trace: Trace): Task[Value] + final def decodeResponse(response: Response)(implicit trace: Trace): Task[Value] + + final def encodeRequest(value: Value): Request + final def encodeResponse[Z](value: Value, outputTypes: Chunk[MediaTypeWithQFactor]): Response +} +``` + +HTTP messages consist of various parts, such as headers, body, and status codes. ZIO HTTP needs to know how to encode and decode each part of the HTTP message. So it has a set of built-in codecs that each one is responsible for a specific part of the HTTP message. + +## Built-in Codecs + +ZIO HTTP provides a set of built-in codecs for common HTTP message parts. Here is a list of built-in codecs: + +```scala +type ContentCodec[A] = HttpCodec[HttpCodecType.Content, A] +type HeaderCodec[A] = HttpCodec[HttpCodecType.Header, A] +type MethodCodec[A] = HttpCodec[HttpCodecType.Method, A] +type QueryCodec[A] = HttpCodec[HttpCodecType.Query, A] +type StatusCodec[A] = HttpCodec[HttpCodecType.Status, A] +``` +These codecs are nothing different from the `HttpCodec` type we saw earlier. They are just specialized versions of `HttpCodec` for specific parts of the HTTP message. + +### ContentCodec + +The `ContentCodec[A]` is a codec for the body of the HTTP message with type `A`. To create a `ContentCodec` we can use the `HttpCodec.content` method. If we want to have codec for a stream of content we can use `HttpCodec.contentStream` or `HttpCodec.binaryStream`: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ + +val stringCodec : ContentCodec[String] = HttpCodec.content[String] +val contentTypedCodec : ContentCodec[String] = HttpCodec.content[String](MediaType.text.plain) +val namedContentCodec : ContentCodec[Int] = HttpCodec.content[Int](name = "age") +val namedContentTypedCodec: ContentCodec[Int] = HttpCodec.content[Int](name = "age", MediaType.text.plain) +``` + +HttpCodecs are composable, we can use `++` to combine two codecs: + +```scala +val nameAndAgeCodec: ContentCodec[(String, Int)] = HttpCodec.content[String]("name") ++ HttpCodec.content[Int]("age") +``` + +We can also `transform` a codec to another codec. In the following example, we transform the previous codec, which is a codec for a tuple of `(String, Int)`, to a codec for a case class `User`: + +```scala +val userContentCodec: ContentCodec[User] = + nameAndAgeCodec.transform[User] { + case (name: String, age: Int) => User(name, age) + }(user => (user.name, user.age)) +``` + +More details about [transforming codecs](#transforming-codecs) will be discussed later in this page. + +Another simple way to create a `ContentCodec` for a case class is to use ZIO Schema. By using ZIO Schema we can derive a schema for a case class and then use it to create a `ContentCodec`: + +```scala mdoc:compile-only +import zio.http.codec._ +import zio.schema._ + +case class User(name: String, age: Int) + +object User { + implicit val schema = DeriveSchema.gen[User] +} + +val userCodec: ContentCodec[User] = HttpCodec.content[User] +``` + +To create a codec for a stream of content we can use `HttpCodec.contentStream`: + +```scala mdoc:compile-only +import zio.stream._ +import zio.http.codec._ + +val temperature: ContentCodec[ZStream[Any, Nothing, Double]] = + HttpCodec.contentStream[Double](name = "temperature") +``` + +To create a codec for a binary stream we can use `HttpCodec.binaryStream`: + +```scala mdoc:compile-only +import zio.stream._ +import zio.http.codec._ + +val binaryStream: ContentCodec[ZStream[Any, Nothing, Byte]] = + HttpCodec.binaryStream(name = "large-file") +``` + +### HeaderCodec + +The `HeaderCodec[A]` is a codec for the headers of the HTTP message with type `A`. To create a `HeaderCodec` we can use the `HttpCodec.header` constructor: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ + +val acceptHeaderCodec: HeaderCodec[Header.Accept] = HttpCodec.header(Header.Accept) +``` + +Or we can use the `HttpCodec.name`, which takes the name of the header as a parameter, which is useful for custom headers: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ +import java.util.UUID + +val acceptHeaderCodec: HeaderCodec[UUID] = HttpCodec.name[UUID]("X-Correlation-ID") +``` + +We can also create a codec that encode/decode multiple headers by combining them with `++`: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ + +val acceptHeaderCodec : HeaderCodec[Header.Accept] = HttpCodec.header(Header.Accept) +val contentTypeHeaderCodec: HeaderCodec[Header.ContentType] = HttpCodec.header(Header.ContentType) + +val acceptAndContentTypeCodec: HeaderCodec[(Header.Accept, Header.ContentType)] = + acceptHeaderCodec ++ contentTypeHeaderCodec +``` + +### MethodCodec + +The `MethodCodec[A]` is a codec for the method of the HTTP message with type `A`. We can use `HttpCodec.method` which takes a `Method` as a parameter to create a `MethodCodec`: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ + +val getMethodCodec: HttpCodec[HttpCodecType.Method, Unit] = HttpCodec.method(Method.GET) +``` + +There are also predefined codecs for all the HTTP methods, e.g. `HttpCodec.connect`, `HttpCodec.delete`, `HttpCodec.get`, `HttpCodec.head`, `HttpCodec.options`, `HttpCodec.patch`, `HttpCodec.post`, `HttpCodec.put`, `HttpCodec.trace`. + +### QueryCodec + +The `QueryCodec[A]` is a codec for the query parameters of the HTTP message with type `A`. To be able to encode and decode query parameters, ZIO HTTP provides a wide range of query codecs. If we are dealing with a single query parameter we can use `HttpCodec.query`, `HttpCodec.queryBool`, `HttpCodec.queryInt`, and `HttpCodec.queryTo`: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ +import java.util.UUID + +val nameQueryCodec : QueryCodec[String] = HttpCodec.query("name") // e.g. ?name=John +val ageQueryCodec : QueryCodec[Int] = HttpCodec.queryInt("age") // e.g. ?age=30 +val activeQueryCodec: QueryCodec[Boolean] = HttpCodec.queryBool("active") // e.g. ?active=true + +// e.g. ?uuid=43abea9e-0b0e-11ef-8d07-e755ec5cd767 +val uuidQueryCodec : QueryCodec[UUID] = HttpCodec.queryTo[UUID]("uuid") +``` + +We can combine multiple query codecs with `++`: + + +If we have multiple query parameters we can use `HttpCodec.queryAll`, `HttpCodec.queryAllBool`, `HttpCodec.queryAllInt`, and `HttpCodec.queryAllTo`: + +```scala mdoc:compile-only +import zio._ +import zio.http._ +import zio.http.codec._ +import java.util.UUID + +val queryAllCodec : QueryCodec[Chunk[String]] = HttpCodec.queryAll("q") // e.g. ?q=one&q=two&q=three +val queryAllIntCodec : QueryCodec[Chunk[Int]] = HttpCodec.queryAllInt("id") // e.g. ?ids=1&ids=2&ids=3 + +// e.g. ?uuid=43abea9e-0b0e-11ef-8d07-e755ec5cd767&uuid=43abea9e-0b0e-11ef-8d07-e755ec5cd768 +val queryAllUUIDCodec: QueryCodec[Chunk[UUID]] = HttpCodec.queryAllTo[UUID]("uuid") +``` + +### StatusCodec + +The `StatusCodec[A]` is a codec for the status code of the HTTP message with type `A`. To create a `StatusCodec` we can use the `HttpCodec.status` method: + +```scala mdoc:compile-only +import zio.http._ +import zio.http.codec._ + +val okStatusCodec: StatusCodec[Unit] = HttpCodec.status(Status.Ok) +``` + +Also, there are predefined codecs for various status codes, e.g. `HttpCodec.Continue`, `HttpCodec.Accepted`, `HttpCodec.NotFound`, etc. + +## Operations + +The primary advantage of `HttpCodec` is its composability, which means we can combine multiple codecs to create new ones. This is useful when we want to encode and decode multiple parts of the HTTP message, such as headers, body, and status codes; so we start by creating codecs for each part and then combine them to create a codec for the whole HTTP message. + +### Combining Codecs Sequentially + +By combining two codecs using the `++` operator, we can create a new codec that sequentially encodes/decodes from left to right: + +```scala mdoc:compile-only +import zio.http.codec._ + +// e.g. ?name=John&age=30 +val queryCodec: QueryCodec[(String, Int)] = HttpCodec.query("name") ++ HttpCodec.queryInt("age") +``` + +### Combining Codecs Alternatively + +There is also a `|` operator that allows us to create a codec that can decode either of the two codecs. Assume we have two query codecs, one for `q` and the other for `query`. We can create a new codec that tries to decode `q` first and if it fails, it tries to decode `query`: + +```scala mdoc:silent +import zio.http.codec._ + +val eitherQueryCodec: QueryCodec[String] = HttpCodec.query("q") | HttpCodec.query("query") +``` + +Assume we have a request + +```scala mdoc:silent +import zio.http._ + +val request: Request = Request(url = URL.root.copy(queryParams = QueryParams("query" -> "foo"))) +``` + +We can decode the query parameter using the `decodeRequest` method: + +```scala mdoc:silent +import zio._ + +val result: Task[String] = eitherQueryCodec.decodeRequest(request) +``` + +```scala mdoc:invisible:reset +``` + +### Optional Codecs + +Sometimes we want to decode a part of the HTTP message only if it exists. We can use the `optional` method to transform a codec to an optional codec: + +```scala mdoc:compile-only +import zio._ +import zio.http._ +import zio.http.codec._ + +val optionalQueryCodec: QueryCodec[Option[String]] = HttpCodec.query("q").optional + +val request = Request(url = URL.root.copy(queryParams = QueryParams("query" -> "foo"))) +val result: Task[Option[String]] = optionalQueryCodec.decodeRequest(request) +``` + +### Expecting a Specific Value + +To write a codec that only accepts a specific value, we can use the `expect` method: + +```scala mdoc:compile-only +import zio._ +import zio.http._ +import zio.http.codec._ + +val expectHeaderValueCodec: HeaderCodec[Unit] = HttpCodec.name[String]("X-Custom-Header").expect("specific-value") +val request: Request = Request(headers = Headers("X-Custom-Header" -> "specific-value")) +val response: Task[Unit] = expectHeaderValueCodec.decodeRequest(request) +``` + +The above codec will only accept the request if the value of the header `X-Custom-Header` is `specific-value`. + +### Transforming Codecs + +HttpCodecs are invariant in their `Value` type parameter, so to transform a codec of type `A` to a codec of type `B`, we need two functions, one for mapping `A` to `B` and the other for mapping `B` to `A`. + +For example, assume we have a codec of type `HttpCodec[HttpCodecType.Content, (String, Int)]`. If we want to transform it to a codec of type `HttpCodec[HttpCodecType.Content, User]`, we require two functions: +- A function that maps a value of type `(String, Int)` to a value of type `User`. +- A function that maps a value of type `User` to a value of type `(String, Int)`. + +```scala mdoc:compile-only +import zio._ +import zio.http.codec._ + +case class User(name: String, age: Int) + +val nameAndAgeCodec: ContentCodec[(String, Int)] = HttpCodec.content[String]("name") ++ HttpCodec.content[Int]("age") + +val userContentCodec: ContentCodec[User] = + nameAndAgeCodec.transform[User] { + case (name: String, age: Int) => User(name, age) + }(user => (user.name, user.age)) +``` + +### Annotating Codecs + +HttpCodec has several methods for annotating codecs: +- `annotate`: To attach a metadata to the codec. +- `named`: To attach a name to the codec. +- `examples`: To attach examples to the codec. +- `??`: To attach a documentation to the codec. + +This additional information can be used for [generating API documentation, e.g. OpenAPI](endpoint.md#openapi-documentation). + +## Usage + +Having a codec for HTTP messages is useful when we want to program declaratively instead of imperative programming. + +Let's compare these two programming styles in ZIO HTTP and see how we can benefit from using `HttpCodec` for writing declarative APIs. + +### Imperative Programming + +When writing an HTTP API, we have to think about a function that takes a `Request` and returns a `Response`, i.e. the handler function. In imperative programming, we have to deal with the low-level details of how to extract the required information from the `Request`, validate it, and finally construct the proper `Response`. In such a way, we have to write all these logics step by step. + +In the following example, we are going to write an API for a bookstore. The API has a single endpoint `/books?id=` that returns the book with the given `id` as a query parameter. If the book is found, it returns a `200 OK` response with the book as the body. If the book is not found, it returns a `404 Not Found` response with an error message: + +```scala mdoc:passthrough +import utils._ +printSource("zio-http-example/src/main/scala/example/endpoint/style/ImperativeProgrammingExample.scala") +``` + +The type of handler in the above example is `Handler[Any, Response, Request, Response]`, which means we have to write a function that takes a `Request` and returns a `Response` and in case of failure, it will return a failure value of type `Response`. In the handler function, we have to manually extract the `id` from the query parameters, then do the business logic to find the book with the given `id`, and finally construct the proper `Response`. + +### Declarative Programming + +In declarative programming, we can separate the two concerns from each other: the definition of the API and its implementation. By having the codecs for the HTTP messages, we can define how the `Request` and `Response` should look like and based on our requirements how they should be encoded and decoded. ZIO Http has the `Endpoint` API that makes it easy to define the API in a declarative way by utilizing `HttpCodec`. After defining the API using `Endpoint`, we can implement it using the `Endpoint#implement` method. + +In the following example, we are going to rewrite the previous example using the `Endpoint` API: + +```scala mdoc:passthrough +import utils._ +printSource("zio-http-example/src/main/scala/example/endpoint/style/DeclarativeProgrammingExample.scala") +``` + +As we will see, we have declared a clear specification of the API and separately implemented it. The very interesting point about the implementation section is that it is not concerned with the low-level details of how to extract the required information from the `Request` and how to construct the proper `Response`. The `implement` method takes a handler of type `Handler[Any, NotFoundError, String, Book]`, which means we have to write a handler function that takes a `String` and returns a `Book` and in case of failure, it will return a `NotFoundError` error. No manual decoding of `Request` and no manual encoding of `Response` is required. So in the handler function, we only have to focus on the business logic. diff --git a/docs/sidebars.js b/docs/sidebars.js index 10c1a6b03f..a5d112662e 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -8,7 +8,7 @@ const sidebars = { // main documentation index link: { type: "doc", id: "index" }, items: [ - "installation", + "installation", // Reference section { @@ -18,9 +18,8 @@ const sidebars = { label: "Reference", items: [ "reference/overview", - "reference/server", - "reference/client", - "reference/endpoint", + "reference/server", + "reference/client", // Routing subsection { @@ -28,12 +27,12 @@ const sidebars = { label: "Routing", items: [ "reference/routing/routes", - "reference/routing/route_pattern", + "reference/routing/route_pattern", "reference/routing/path_codec", ], }, - - "reference/handler", + + "reference/handler", // HTTP Messages subsection { @@ -49,9 +48,9 @@ const sidebars = { type: "category", label: "Headers", items: [ - "reference/headers/headers", - "reference/headers/session/cookies", - "reference/headers/session/flash", + "reference/headers/headers", + "reference/headers/session/cookies", + "reference/headers/session/flash", ], }, @@ -60,25 +59,31 @@ const sidebars = { type: "category", label: "Message Body", items: [ - "reference/body/body", + "reference/body/body", "reference/body/form", "reference/body/binary_codecs", - "reference/body/template", + "reference/body/template", ], }, - ], }, - + { + type: "category", + label: "Declarative Endpoints", + items: [ + "reference/endpoint", + "reference/http-codec", + ], + }, // Aspects subsection { type: "category", label: "Aspects", items: [ - "reference/aop/protocol-stack", - "reference/aop/middleware", - "reference/aop/handler_aspect", + "reference/aop/protocol-stack", + "reference/aop/middleware", + "reference/aop/handler_aspect", ], }, @@ -87,8 +92,8 @@ const sidebars = { type: "category", label: "WebSocket", items: [ - "reference/socket/socket", - "reference/socket/websocketframe", + "reference/socket/socket", + "reference/socket/websocketframe", ], }, ], @@ -103,41 +108,39 @@ const sidebars = { }, "faq", { - // Subcategory: Tutorials + // Subcategory: Tutorials type: "category", label: "Tutorials", - items: [ - "tutorials/testing-http-apps", - ], + items: ["tutorials/testing-http-apps"], }, + // Examples section { type: "category", label: "Examples", link: { type: "doc", id: "examples/index" }, items: [ - "examples/hello-world", - "examples/http-client-server", - "examples/https-client-server", + "examples/hello-world", + "examples/http-client-server", + "examples/https-client-server", "examples/serving-static-files", - "examples/html-templating", - "examples/websocket", - "examples/streaming", - "examples/endpoint", - "examples/middleware-cors-handling", - "examples/authentication", + "examples/html-templating", + "examples/websocket", + "examples/streaming", + "examples/endpoint", + "examples/middleware-cors-handling", + "examples/authentication", "examples/graceful-shutdown", - "examples/cli", - "examples/concrete-entity", - "examples/multipart-form-data", + "examples/cli", + "examples/concrete-entity", + "examples/multipart-form-data", "examples/server-sent-events-in-endpoints", ], }, - "faq", ], }, ], }; -module.exports = sidebars; \ No newline at end of file +module.exports = sidebars; diff --git a/zio-http-example/src/main/scala/example/endpoint/EndpointWithError.scala b/zio-http-example/src/main/scala/example/endpoint/style/DeclarativeProgrammingExample.scala similarity index 53% rename from zio-http-example/src/main/scala/example/endpoint/EndpointWithError.scala rename to zio-http-example/src/main/scala/example/endpoint/style/DeclarativeProgrammingExample.scala index db4a1a050a..29bc65f70e 100644 --- a/zio-http-example/src/main/scala/example/endpoint/EndpointWithError.scala +++ b/zio-http-example/src/main/scala/example/endpoint/style/DeclarativeProgrammingExample.scala @@ -1,47 +1,44 @@ -package example.endpoint +package example.endpoint.style import zio._ import zio.schema.{DeriveSchema, Schema} import zio.http._ -import zio.http.codec.PathCodec +import zio.http.codec.QueryCodec import zio.http.endpoint.Endpoint import zio.http.endpoint.EndpointMiddleware.None -object EndpointWithError extends ZIOAppDefault { +object DeclarativeProgrammingExample extends ZIOAppDefault { case class Book(title: String, authors: List[String]) object Book { implicit val schema: Schema[Book] = DeriveSchema.gen } - case class NotFoundError(error: String, message: String) + case class NotFoundError(message: String) object NotFoundError { implicit val schema: Schema[NotFoundError] = DeriveSchema.gen } object BookRepo { - def find(id: Int): ZIO[Any, String, Book] = { - if (id == 1) + def find(id: String): ZIO[Any, NotFoundError, Book] = { + if (id == "1") ZIO.succeed(Book("Zionomicon", List("John A. De Goes", "Adam Fraser"))) else - ZIO.fail("Not found") + ZIO.fail(NotFoundError("The requested book was not found!")) } } - val endpoint: Endpoint[Int, Int, NotFoundError, Book, None] = - Endpoint(RoutePattern.GET / "books" / PathCodec.int("id")) + val endpoint: Endpoint[Unit, String, NotFoundError, Book, None] = + Endpoint(RoutePattern.GET / "books") + .query(QueryCodec.query("id")) .out[Book] .outError[NotFoundError](Status.NotFound) - val getBookHandler: Handler[Any, NotFoundError, Int, Book] = - handler { (id: Int) => - BookRepo - .find(id) - .mapError(err => NotFoundError(err, "The requested book was not found. Please try using a different ID.")) - } + val getBookHandler: Handler[Any, NotFoundError, String, Book] = + handler(BookRepo.find(_)) val routes = endpoint.implement(getBookHandler).toRoutes @@ Middleware.debug diff --git a/zio-http-example/src/main/scala/example/endpoint/style/ImperativeProgrammingExample.scala b/zio-http-example/src/main/scala/example/endpoint/style/ImperativeProgrammingExample.scala new file mode 100644 index 0000000000..00f379f95b --- /dev/null +++ b/zio-http-example/src/main/scala/example/endpoint/style/ImperativeProgrammingExample.scala @@ -0,0 +1,38 @@ +package example.endpoint.style + +import zio._ + +import zio.schema.codec.JsonCodec.schemaBasedBinaryCodec +import zio.schema.{DeriveSchema, Schema} + +import zio.http._ + +object ImperativeProgrammingExample extends ZIOAppDefault { + + case class Book(title: String, authors: List[String]) + + object Book { + implicit val schema: Schema[Book] = DeriveSchema.gen[Book] + } + + object BookRepo { + + case class NotFoundError(message: String) + + def find(id: String): ZIO[Any, NotFoundError, Book] = + if (id == "1") + ZIO.succeed(Book("Zionomicon", List("John A. De Goes", "Adam Fraser"))) + else + ZIO.fail(NotFoundError("The requested book was not found!")) + } + + val route: Route[Any, Response] = + Method.GET / "books" -> handler { (req: Request) => + for { + id <- ZIO.fromOption(req.queryParam("id")).orElseFail(Response.badRequest("Missing query parameter id")) + books <- BookRepo.find(id).mapError(err => Response.notFound(err.message)) + } yield Response.ok.copy(body = Body.from(books)) + } + + def run = Server.serve(route.toRoutes).provide(Server.default) +}