From a933181b80afad6bdf18197a7fe0a3809d284b57 Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Thu, 4 May 2023 18:10:38 +0200 Subject: [PATCH] Add methods to `Endpoint` to apply any kind of in/out codec - add some convenience methods for creating header codecs - make text codecs implicit for easy usage in header and query codec --- .../scala/zio/http/codec/HeaderCodecs.scala | 25 +++++++ .../scala/zio/http/codec/QueryCodecs.scala | 1 + .../main/scala/zio/http/codec/TextCodec.scala | 8 +-- .../scala/zio/http/endpoint/Endpoint.scala | 19 ++++- .../zio/http/endpoint/EndpointSpec.scala | 71 +++++++++++++++++++ 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala b/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala index 4873fc5d20..dbfe807019 100644 --- a/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala +++ b/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala @@ -16,6 +16,8 @@ package zio.http.codec +import scala.util.Try + import zio.http.Header import zio.http.Header.HeaderType @@ -27,6 +29,29 @@ private[codec] trait HeaderCodecs { headerCodec(headerType.name, TextCodec.string) .transformOrFailLeft(headerType.parse(_), headerType.render(_)) + def name[A](name: String)(implicit codec: TextCodec[A]): HeaderCodec[A] = + headerCodec(name, codec) + + def nameTransform[A, B](name: String, parse: B => Either[String, A], render: A => B)(implicit + codec: TextCodec[B], + ): HeaderCodec[A] = + headerCodec(name, codec).transformOrFailLeft(parse, render) + + def nameTransformOpt[A, B](name: String, parse: B => Option[A], render: A => B)(implicit + codec: TextCodec[B], + ): HeaderCodec[A] = + headerCodec(name, codec).transformOrFailLeft(parse(_).toRight(s"Failed to parse header $name"), render) + + def nameTransformOrFail[A, B]( + name: String, + parse: B => A, + render: A => B, + )(implicit codec: TextCodec[B]): HeaderCodec[A] = + headerCodec(name, codec).transformOrFailLeft( + s => Try(parse(s)).toEither.left.map(e => s"Failed to parse header $name: ${e.getMessage}"), + render, + ) + final val accept: HeaderCodec[Header.Accept] = header(Header.Accept) final val acceptEncoding: HeaderCodec[Header.AcceptEncoding] = header(Header.AcceptEncoding) final val acceptLanguage: HeaderCodec[Header.AcceptLanguage] = header(Header.AcceptLanguage) diff --git a/zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala index bfce625e9b..da4ee7d049 100644 --- a/zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -28,4 +28,5 @@ private[codec] trait QueryCodecs { def queryAs[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = HttpCodec.Query(name, codec) + } diff --git a/zio-http/src/main/scala/zio/http/codec/TextCodec.scala b/zio-http/src/main/scala/zio/http/codec/TextCodec.scala index a030924d0a..3cf8f527a4 100644 --- a/zio-http/src/main/scala/zio/http/codec/TextCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/TextCodec.scala @@ -45,15 +45,15 @@ sealed trait TextCodec[A] extends PartialFunction[String, A] { self => } object TextCodec { - val boolean: TextCodec[Boolean] = BooleanCodec + implicit val boolean: TextCodec[Boolean] = BooleanCodec def constant(string: String): TextCodec[Unit] = Constant(string) - val int: TextCodec[Int] = IntCodec + implicit val int: TextCodec[Int] = IntCodec - val string: TextCodec[String] = StringCodec + implicit val string: TextCodec[String] = StringCodec - val uuid: TextCodec[UUID] = UUIDCodec + implicit val uuid: TextCodec[UUID] = UUIDCodec final case class Constant(string: String) extends TextCodec[Unit] { diff --git a/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala b/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala index 48746d95c7..a92443b0c4 100644 --- a/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala +++ b/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala @@ -25,7 +25,6 @@ import zio.stream.ZStream import zio.schema._ import zio.http.Status -import zio.http.codec.HttpCodecType.ResponseType import zio.http.codec._ import zio.http.endpoint.Endpoint.OutErrors @@ -145,6 +144,15 @@ final case class Endpoint[Input, Err, Output, Middleware <: EndpointMiddleware]( ): Endpoint[combiner.Out, Err, Output, Middleware] = copy(input = input ++ HttpCodec.Content(schema)) + /** + * Returns a new endpoint derived from this one, whose request must satisfy + * the specified codec. + */ + def inCodec[Input2](codec: HttpCodec[HttpCodecType.RequestType, Input2])(implicit + combiner: Combiner[Input, Input2], + ): Endpoint[combiner.Out, Err, Output, Middleware] = + copy(input = input ++ codec) + /** * Returns a new endpoint derived from this one whose middleware is composed * from the existing middleware of this endpoint, and the specified @@ -193,6 +201,15 @@ final case class Endpoint[Input, Err, Output, Middleware <: EndpointMiddleware]( def outErrors[Err2]: OutErrors[Input, Err, Output, Middleware, 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[Output, Output2], + ): Endpoint[Input, Err, alt.Out, Middleware] = + copy(output = self.output | codec) + /** * Returns a new endpoint derived from this one, whose output type is a stream * of the specified type for the ok status code. diff --git a/zio-http/src/test/scala/zio/http/endpoint/EndpointSpec.scala b/zio-http/src/test/scala/zio/http/endpoint/EndpointSpec.scala index e2e55aade4..dcc94b1b4f 100644 --- a/zio-http/src/test/scala/zio/http/endpoint/EndpointSpec.scala +++ b/zio-http/src/test/scala/zio/http/endpoint/EndpointSpec.scala @@ -234,6 +234,77 @@ object EndpointSpec extends ZIOSpecDefault { "path(users, 123, posts, 555, comments, 777, replies, 999)", ) + }, + test("composite in codecs") { + val headerOrQuery = HeaderCodec.name[String]("X-Header") | QueryCodec.query("header") + + val endpoint = Endpoint.get(literal("test")).out[String].inCodec(headerOrQuery) + + val routes = endpoint.implement(header => ZIO.succeed(header)) + + val request = Request.get( + URL + .decode("/test?header=query-value") + .toOption + .get, + ) + + val requestWithHeaderAndQuery = request.addHeader("X-Header", "header-value") + + val requestWithHeader = Request + .get( + URL + .decode("/test") + .toOption + .get, + ) + .addHeader("X-Header", "header-value") + + for { + response <- routes.toApp.runZIO(request) + onlyQuery <- response.body.asString.orDie + response <- routes.toApp.runZIO(requestWithHeader) + onlyHeader <- response.body.asString.orDie + response <- routes.toApp.runZIO(requestWithHeaderAndQuery) + headerAndQuery <- response.body.asString.orDie + } yield assertTrue( + onlyQuery == """"query-value"""", + onlyHeader == """"header-value"""", + headerAndQuery == """"header-value"""", + ) + }, + test("composite out codecs") { + val headerOrQuery = HeaderCodec.name[String]("X-Header") | StatusCodec.status(Status.Created) + + val endpoint = Endpoint.get(literal("test")).query(QueryCodec.queryBool("Created")).outCodec(headerOrQuery) + + val routes = + endpoint.implement(created => if (created) ZIO.succeed(Right(())) else ZIO.succeed(Left("not created"))) + + val requestCreated = Request.get( + URL + .decode("/test?Created=true") + .toOption + .get, + ) + + val requestNotCreated = Request.get( + URL + .decode("/test?Created=false") + .toOption + .get, + ) + + for { + notCreated <- routes.toApp.runZIO(requestNotCreated) + header = notCreated.rawHeader("X-Header").get + response <- routes.toApp.runZIO(requestCreated) + } yield assertTrue( + header == "not created", + notCreated.status == Status.Ok, + response.status == Status.Created, + ) + }, suite("request bodies")( test("simple input") {