From 3f0625caeea4c6d1f9eecd11d403f286fc5ae0bd Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Fri, 30 Aug 2024 10:34:43 +0200 Subject: [PATCH] Add convenience method for out header and extend docs (#3054) --- docs/reference/endpoint.md | 112 ++++++++++++++++++ .../scala/zio/http/endpoint/RequestSpec.scala | 45 +++++++ .../scala/zio/http/endpoint/Endpoint.scala | 23 ++-- 3 files changed, 171 insertions(+), 9 deletions(-) diff --git a/docs/reference/endpoint.md b/docs/reference/endpoint.md index ec0c8c0b87..ebfce576c8 100644 --- a/docs/reference/endpoint.md +++ b/docs/reference/endpoint.md @@ -221,6 +221,25 @@ val endpoint: Endpoint[Unit, String, ZNothing, List[Book], AuthType.None] = In the above example, we defined an endpoint that describes a query parameter `q` as input and returns a list of `Book` as output. The `Endpoint#out` method has multiple overloads that can be used to describe other properties of the output, such as the status code, media type, and documentation. +We can also add custom headers to the output using the `Endpoint#outHeader` method: + +```scala mdoc:compile-only +import zio.http._ +import zio.schema._ + +case class Book(title: String, author: String) + +object Book { + implicit val schema: Schema[Book] = DeriveSchema.gen[Book] +} + +val endpoint: Endpoint[Unit, String, ZNothing, (List[Book], Header.Date), AuthType.None] = + Endpoint(RoutePattern.GET / "books") + .query(HttpCodec.query[String]("q")) + .out[List[Book]] + .outHeader(HttpCodec.date) +``` + Sometimes based on the condition, we might want to return different types of responses. We can use the `Endpoint#out` method multiple times to describe different output types: ```scala mdoc:compile-only @@ -272,8 +291,101 @@ object EndpointWithMultipleOutputTypes extends ZIOAppDefault { In the above example, we defined an endpoint that describes a path parameter `id` as input and returns either a `Book` or an `Article` as output. +With multiple outputs, we can define if all of them or just some should add an output header, by the order of calling `out` and `outHeader` methods: + +```scala mdoc:compile-only +import zio._ +import zio.http._ +import zio.http.endpoint._ +import zio.schema._ + +case class Book(title: String, author: String) + +object Book { + implicit val schema: Schema[Book] = DeriveSchema.gen +} + +case class Article(title: String, author: String) + +object Article { + implicit val schema: Schema[Article] = DeriveSchema.gen +} +// header will be added to the first output +val endpoint: Endpoint[Unit, Unit, ZNothing, Either[Article, (Book, Header.Date)], AuthType.None] = + Endpoint(RoutePattern.GET / "resources") + .out[Book] + .outHeader(HttpCodec.date) + .out[Article] + +// header will be added to all outputs +val endpoint2: Endpoint[Unit, Unit, ZNothing, (Either[Article, Book], Header.Date), AuthType.None] = + Endpoint(RoutePattern.GET / "resources") + .out[Book] + .out[Article] + .outHeader(HttpCodec.date) +``` + +A call to `outHeder` will require to provide the header together with all outputs defined before it. + Sometimes we might want more control over the output properties, in such cases, we can provide a custom `HttpCodec` that describes the output properties using the `Endpoint#outCodec` method. +This can be very useful when we only want to add headers to a subset of outputs for example: + +```scala mdoc:compile-only +import zio._ +import zio.http._ +import zio.http.endpoint._ +import zio.schema._ + +case class Book(title: String, author: String) + +object Book { + implicit val schema: Schema[Book] = DeriveSchema.gen +} + +case class Article(title: String, author: String) + +object Article { + implicit val schema: Schema[Article] = DeriveSchema.gen +} +val endpoint: Endpoint[Unit, Unit, ZNothing, Either[(Article, Header.Date), Book], AuthType.None] = + Endpoint(RoutePattern.GET / "resources") + .out[Book] + .outCodec(HttpCodec.content[Article] ++ HttpCodec.date) +``` +Or when we want to reuse the same codec for multiple endpoints: + +```scala mdoc:compile-only +import zio._ +import zio.http._ +import zio.http.endpoint._ +import zio.schema._ + +case class Book(title: String, author: String) + +object Book { + implicit val schema: Schema[Book] = DeriveSchema.gen +} + +case class Article(title: String, author: String) + +object Article { + implicit val schema: Schema[Article] = DeriveSchema.gen +} +val bookCodec = HttpCodec.content[Book] ++ HttpCodec.date +val articleCodec = HttpCodec.content[Article] ++ HttpCodec.date +val endpoint1: Endpoint[Unit, Unit, ZNothing, (Book, Header.Date), AuthType.None] = + Endpoint(RoutePattern.GET / "books") + .outCodec(bookCodec) + +val endpoint2: Endpoint[Unit, Unit, ZNothing, (Article, Header.Date), AuthType.None] = + Endpoint(RoutePattern.GET / "articles") + .outCodec(articleCodec) + +val endpoint3: Endpoint[Unit, Unit, ZNothing, Either[(Article, Header.Date), (Book, Header.Date)], AuthType.None] = + Endpoint(RoutePattern.GET / "resources") + .outCodec(articleCodec | bookCodec) +``` ## Describing Failures For failure outputs, we can describe the output properties using the `Endpoint#outError*` methods. Let's see an example: diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/RequestSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/RequestSpec.scala index 78d5998948..7e6e1fb6c8 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/RequestSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/RequestSpec.scala @@ -92,6 +92,51 @@ object RequestSpec extends ZIOHttpSpec { assertTrue(contentType == Some(ContentType(MediaType.text.`plain`))) } }, + test("custom out header") { + val endpoint = + Endpoint(GET / "posts") + .out[String](MediaType.text.plain) + .outHeader(HttpCodec.age) + val routes = endpoint.implementAs(("test", Header.Age(1.minute))).toRoutes + for { + response <- routes.runZIO(Request.get(URL.decode("/posts").toOption.get)) + age = response.header(Header.Age) + body <- response.body.asString.orDie + } yield assertTrue(age.contains(Header.Age(1.minute)), body == "test") + }, + test("custom out header for only one output") { + val endpoint = + Endpoint(GET / "posts") + .out[String](MediaType.text.plain) + .outHeader(HttpCodec.age) + .out[Int](MediaType.text.plain) + val routesRight = endpoint.implementAs(Right(("test", Header.Age(1.minute)))).toRoutes + val routesLeft = endpoint.implementAs(Left(1)).toRoutes + for { + responseRight <- routesRight.runZIO(Request.get(URL.decode("/posts").toOption.get)) + ageRight = responseRight.header(Header.Age) + bodyRight <- responseRight.body.asString.orDie + responseLeft <- routesLeft.runZIO(Request.get(URL.decode("/posts").toOption.get)) + ageLeft = responseLeft.header(Header.Age) + bodyLeft <- responseLeft.body.asString.orDie + } yield assertTrue( + ageRight.contains(Header.Age(1.minute)), + bodyRight == "test", + ageLeft.isEmpty, + bodyLeft == "1", + ) + }, + test("custom out header with outCodec") { + val endpoint = + Endpoint(GET / "posts") + .outCodec((HttpCodec.content[String](MediaType.text.plain) | HttpCodec.content[Int]) ++ HttpCodec.age) + val routes = endpoint.implementAs((Left("test"), Header.Age(1.minute))).toRoutes + for { + response <- routes.runZIO(Request.get(URL.decode("/posts").toOption.get)) + age = response.header(Header.Age) + body <- response.body.asString.orDie + } yield assertTrue(age.contains(Header.Age(1.minute)), body == "test") + }, test("multiple Accept headers") { check(Gen.int) { id => val endpoint = diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala index 0d6e737922..367795ae57 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala @@ -617,6 +617,15 @@ final case class Endpoint[PathInput, Input, Err, Output, Auth <: AuthType]( authType, ) + /** + * Returns a new endpoint derived from this one, whose response must satisfy + * the specified codec. + */ + def outCodec[Output2](codec: HttpCodec[HttpCodecType.ResponseType, Output2])(implicit + alt: Alternator[Output2, Output], + ): Endpoint[PathInput, Input, Err, alt.Out, Auth] = + copy(output = codec | self.output) + /** * Converts a codec error into a specific error type. The given media types * are sorted by q-factor. Beginning with the highest q-factor. @@ -650,14 +659,10 @@ final case class Endpoint[PathInput, Input, Err, Output, Auth <: AuthType]( def outErrors[Err2]: OutErrors[PathInput, Input, Err, Output, Auth, Err2] = OutErrors(self) - /** - * Returns a new endpoint derived from this one, whose response must satisfy - * the specified codec. - */ - def outCodec[Output2](codec: HttpCodec[HttpCodecType.ResponseType, Output2])(implicit - alt: Alternator[Output2, Output], - ): Endpoint[PathInput, Input, Err, alt.Out, Auth] = - copy(output = codec | self.output) + def outHeader[A](codec: HeaderCodec[A])(implicit + combiner: Combiner[Output, A], + ): Endpoint[PathInput, Input, Err, combiner.Out, Auth] = + copy(output = self.output ++ codec) /** * Returns a new endpoint derived from this one, whose output type is a stream @@ -794,7 +799,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Auth <: AuthType]( copy(input = self.input ++ codec) /** - * Adds tags to the endpoint. The are used for documentation generation. For + * Adds tags to the endpoint. They are used for documentation generation. For * example to group endpoints for OpenAPI. */ def tag(tag: String, tags: String*): Endpoint[PathInput, Input, Err, Output, Auth] =