From 60c31e2b680b41d1caade8f3df047b2cd2a6b0ea Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Mon, 7 Aug 2023 19:36:21 +0200 Subject: [PATCH] Generalize the concept of metadata in `HttpCodec` (#2363) --- .../zio/http/endpoint/cli/CliEndpoint.scala | 27 +++---- .../main/scala/zio/http/codec/HttpCodec.scala | 72 ++++++++++++++----- .../http/codec/internal/AtomizedCodecs.scala | 3 +- .../zio/http/codec/internal/Mechanic.scala | 17 ++--- .../scala/zio/http/endpoint/Endpoint.scala | 8 +-- .../scala/zio/http/codec/HttpCodecSpec.scala | 10 +-- .../zio/http/endpoint/EndpointSpec.scala | 16 ++--- 7 files changed, 94 insertions(+), 59 deletions(-) diff --git a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala index 3bf492e192..360ac3ca7d 100644 --- a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala +++ b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala @@ -8,6 +8,7 @@ import zio.json.ast._ import zio.schema._ import zio.http._ +import zio.http.codec.HttpCodec.Metadata import zio.http.codec._ import zio.http.endpoint._ @@ -84,12 +85,12 @@ private[cli] object CliEndpoint { r <- fromInput(right) } yield l ++ r - case HttpCodec.Content(schema, _, _, _) => fromSchema(schema) - case HttpCodec.ContentStream(schema, _, _, _) => fromSchema(schema) - case HttpCodec.Empty => Set.empty - case HttpCodec.Fallback(left, right) => fromInput(left) ++ fromInput(right) - case HttpCodec.Halt => Set.empty - case HttpCodec.Query(name, queryCodec, _) => + case HttpCodec.Content(schema, _, _, _) => fromSchema(schema) + case HttpCodec.ContentStream(schema, _, _, _) => fromSchema(schema) + case HttpCodec.Empty => Set.empty + case HttpCodec.Fallback(left, right) => fromInput(left) ++ fromInput(right) + case HttpCodec.Halt => Set.empty + case HttpCodec.Query(name, queryCodec, _) => queryCodec.asInstanceOf[TextCodec[_]] match { case TextCodec.UUIDCodec => Set( @@ -160,7 +161,7 @@ private[cli] object CliEndpoint { ), ) } - case HttpCodec.Header(name, textCodec, _) => + case HttpCodec.Header(name, textCodec, _) => textCodec.asInstanceOf[TextCodec[_]] match { case TextCodec.UUIDCodec => Set( @@ -226,7 +227,7 @@ private[cli] object CliEndpoint { ), ) } - case HttpCodec.Method(codec, _) => + case HttpCodec.Method(codec, _) => codec.asInstanceOf[SimpleCodec[_, _]] match { case SimpleCodec.Specified(method) => Set( @@ -240,7 +241,7 @@ private[cli] object CliEndpoint { case SimpleCodec.Unspecified() => Set.empty } - case HttpCodec.Path(pathCodec, _) => + case HttpCodec.Path(pathCodec, _) => pathCodec.segments.toSet.map { (segment: SegmentCodec[_]) => segment match { case _: SegmentCodec.Empty => @@ -311,10 +312,10 @@ private[cli] object CliEndpoint { ??? } } - case HttpCodec.Status(_, _) => Set.empty - case HttpCodec.TransformOrFail(api, _, _) => fromInput(api) - case HttpCodec.WithDoc(in, doc) => fromInput(in).map(_ describeOptions doc.toPlaintext()) - case HttpCodec.WithExamples(in, _) => fromInput(in) + case HttpCodec.Status(_, _) => Set.empty + case HttpCodec.TransformOrFail(api, _, _) => fromInput(api) + case HttpCodec.Annotated(in, Metadata.Documented(doc)) => fromInput(in).map(_ describeOptions doc.toPlaintext()) + case HttpCodec.Annotated(in, _) => fromInput(in) } private def fromSchema(schema: zio.schema.Schema[_]): Set[CliEndpoint[_]] = { diff --git a/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala index ac53da4639..f1b864934a 100644 --- a/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala @@ -27,6 +27,7 @@ import zio.stream.ZStream import zio.schema.Schema import zio.http._ +import zio.http.codec.HttpCodec.{Annotated, Metadata} /** * A [[zio.http.codec.HttpCodec]] represents a codec for a part of an HTTP @@ -42,13 +43,15 @@ import zio.http._ */ sealed trait HttpCodec[-AtomTypes, Value] { self => + private lazy val encoderDecoder = zio.http.codec.internal.EncoderDecoder(self) /** * Returns a new codec that is the same as this one, but has attached docs, * which will render whenever docs are generated from the codec. */ - final def ??(doc: Doc): HttpCodec[AtomTypes, Value] = HttpCodec.WithDoc(self, doc) + final def ??(doc: Doc): HttpCodec[AtomTypes, Value] = + HttpCodec.Annotated(self, Metadata.Documented(doc)) final def |[AtomTypes1 <: AtomTypes, Value2]( that: HttpCodec[AtomTypes1, Value2], @@ -108,6 +111,13 @@ sealed trait HttpCodec[-AtomTypes, Value] { */ final def alternatives: Chunk[HttpCodec[AtomTypes, Value]] = HttpCodec.flattenFallbacks(self) + /** + * Returns a new codec that is the same as this one, but has attached + * metadata, such as docs. + */ + def annotate(metadata: Metadata[Value]): HttpCodec[AtomTypes, Value] = + HttpCodec.Annotated(self, metadata) + /** * Reinterprets this codec as a query codec assuming evidence that this * interpretation is sound. @@ -193,13 +203,13 @@ sealed trait HttpCodec[-AtomTypes, Value] { ): Z = encoderDecoder.encodeWith(value)(f) - def examples(examples: Iterable[Value]): HttpCodec[AtomTypes, Value] = - HttpCodec.WithExamples(self, Chunk.fromIterable(examples)) + def examples(examples: Iterable[(String, Value)]): HttpCodec[AtomTypes, Value] = + HttpCodec.Annotated(self, Metadata.Examples(Chunk.fromIterable(examples).toMap)) - def examples(example1: Value, examples: Value*): HttpCodec[AtomTypes, Value] = - HttpCodec.WithExamples(self, example1 +: Chunk.fromIterable(examples)) + def examples(example1: (String, Value), examples: (String, Value)*): HttpCodec[AtomTypes, Value] = + HttpCodec.Annotated(self, Metadata.Examples((example1 +: Chunk.fromIterable(examples)).toMap)) - def examples: Chunk[Value] = Chunk.empty + def examples: Map[String, Value] = Map.empty /** * Returns a new codec that will expect the value to be equal to the specified @@ -213,13 +223,26 @@ sealed trait HttpCodec[-AtomTypes, Value] { _ => expected, ) + def named(name: String): HttpCodec[AtomTypes, Value] = + HttpCodec.Annotated(self, Metadata.Named(name)) + + /** + * Returns a new codec that is the same as this one, but has attached a name. + * This name is used for documentation generation. + */ + def named(named: Metadata.Named[Value]): HttpCodec[AtomTypes, Value] = + HttpCodec.Annotated(self, Metadata.Named(named.name)) + /** * Returns a new codec, where the value produced by this one is optional. */ final def optional: HttpCodec[AtomTypes, Option[Value]] = - self - .orElseEither(HttpCodec.empty) - .transform[Option[Value]](_.swap.toOption, _.fold[Either[Unit, Value]](Left(()))(Right(_)).swap) + Annotated( + self + .orElseEither(HttpCodec.empty) + .transform[Option[Value]](_.swap.toOption, _.fold[Either[Unit, Value]](Left(()))(Right(_)).swap), + Metadata.Optional(), + ) final def orElseEither[AtomTypes1 <: AtomTypes, Value2]( that: HttpCodec[AtomTypes1, Value2], @@ -558,11 +581,30 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with def index(index: Int): Header[A] = copy(index = index) } - private[http] final case class WithDoc[AtomType, A](in: HttpCodec[AtomType, A], doc: Doc) - extends HttpCodec[AtomType, A] + private[http] final case class Annotated[AtomTypes, Value]( + codec: HttpCodec[AtomTypes, Value], + metadata: Metadata[Value], + ) extends HttpCodec[AtomTypes, Value] { + override def examples: Map[String, Value] = + metadata match { + case value: Metadata.Examples[Value] => + value.examples ++ codec.examples + case _ => + codec.examples + } + } + + sealed trait Metadata[Value] + + object Metadata { + final case class Named[A](name: String) extends Metadata[A] - private[http] final case class WithExamples[AtomType, A](in: HttpCodec[AtomType, A], override val examples: Chunk[A]) - extends HttpCodec[AtomType, A] + final case class Optional[A]() extends Metadata[Option[A]] + + final case class Examples[A](examples: Map[String, A]) extends Metadata[A] + + final case class Documented[A](doc: Doc) extends Metadata[A] + } private[http] final case class TransformOrFail[AtomType, X, A]( api: HttpCodec[AtomType, X], @@ -614,9 +656,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with r <- rewrite[T, combine.Right](right) } yield HttpCodec.Combine(l, r, combiner) - case HttpCodec.WithDoc(in, doc) => rewrite[T, B](in).map(_ ?? doc) - - case HttpCodec.WithExamples(in, examples) => rewrite[T, B](in).map(_.examples(examples)) + case HttpCodec.Annotated(in, metadata) => rewrite[T, B](in).map(_.annotate(metadata)) case HttpCodec.Empty => Chunk.single(HttpCodec.Empty) diff --git a/zio-http/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala b/zio-http/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala index 3776e4548a..4c18c8e466 100644 --- a/zio-http/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala +++ b/zio-http/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala @@ -82,8 +82,7 @@ private[http] object AtomizedCodecs { case Combine(left, right, _) => flattenedAtoms(left) ++ flattenedAtoms(right) case atom: Atom[_, _] => Chunk(atom) case map: TransformOrFail[_, _, _] => flattenedAtoms(map.api) - case WithDoc(api, _) => flattenedAtoms(api) - case WithExamples(api, _) => flattenedAtoms(api) + case Annotated(api, _) => flattenedAtoms(api) case Empty => Chunk.empty case Halt => Chunk.empty case Fallback(_, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") diff --git a/zio-http/src/main/scala/zio/http/codec/internal/Mechanic.scala b/zio-http/src/main/scala/zio/http/codec/internal/Mechanic.scala index e9f5b0f2fd..632e5cd26d 100644 --- a/zio-http/src/main/scala/zio/http/codec/internal/Mechanic.scala +++ b/zio-http/src/main/scala/zio/http/codec/internal/Mechanic.scala @@ -42,11 +42,10 @@ private[http] object Mechanic { val (api2, resultIndices) = indexedImpl(api, indices) (TransformOrFail(api2, f, g).asInstanceOf[HttpCodec[R, A]], resultIndices) - case WithDoc(api, _) => indexedImpl(api.asInstanceOf[HttpCodec[R, A]], indices) - case WithExamples(api, _) => indexedImpl(api.asInstanceOf[HttpCodec[R, A]], indices) - case Empty => (Empty.asInstanceOf[HttpCodec[R, A]], indices) - case Halt => (Halt.asInstanceOf[HttpCodec[R, A]], indices) - case Fallback(_, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") + case Annotated(api, _) => indexedImpl(api.asInstanceOf[HttpCodec[R, A]], indices) + case Empty => (Empty.asInstanceOf[HttpCodec[R, A]], indices) + case Halt => (Halt.asInstanceOf[HttpCodec[R, A]], indices) + case Fallback(_, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") } def makeConstructor[R, A]( @@ -95,9 +94,7 @@ private[http] object Mechanic { case Right(value) => value } - case WithDoc(api, _) => makeConstructorLoop(api) - - case WithExamples(api, _) => makeConstructorLoop(api) + case Annotated(api, _) => makeConstructorLoop(api) case Empty => _ => coerce(()) @@ -140,9 +137,7 @@ private[http] object Mechanic { inputsBuilder, ) - case WithDoc(api, _) => makeDeconstructorLoop(api) - - case WithExamples(api, _) => makeDeconstructorLoop(api) + case Annotated(api, _) => makeDeconstructorLoop(api) case Empty => (_, _) => () 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 fdedb14612..55538a8392 100644 --- a/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala +++ b/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala @@ -128,15 +128,15 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM ): Invocation[PathInput, Input, Err, Output, Middleware] = Invocation(self, ev((a, b, c, d, e, f, g, h, i, j, k, l))) - def examplesIn(examples: Input*): Endpoint[PathInput, Input, Err, Output, Middleware] = + def examplesIn(examples: (String, Input)*): Endpoint[PathInput, Input, Err, Output, Middleware] = copy(input = self.input.examples(examples)) - def examplesIn: Chunk[Input] = self.input.examples + def examplesIn: Map[String, Input] = self.input.examples - def examplesOut(examples: Output*): Endpoint[PathInput, Input, Err, Output, Middleware] = + def examplesOut(examples: (String, Output)*): Endpoint[PathInput, Input, Err, Output, Middleware] = copy(output = self.output.examples(examples)) - def examplesOut: Chunk[Output] = self.output.examples + def examplesOut: Map[String, Output] = self.output.examples /** * Returns a new endpoint that requires the specified headers to be present. diff --git a/zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala b/zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala index e7f0f5e841..559e836b93 100644 --- a/zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala +++ b/zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala @@ -22,7 +22,6 @@ import zio._ import zio.test._ import zio.http._ -import zio.http.codec._ object HttpCodecSpec extends ZIOSpecDefault { val googleUrl = URL.decode("http://google.com").toOption.get @@ -144,18 +143,19 @@ object HttpCodecSpec extends ZIOSpecDefault { ) + suite("Codec with examples") { test("with examples") { - val userCodec = HttpCodec.empty.const("foo").examples("John", "Jane") + val userCodec = HttpCodec.empty.const("foo").examples("user" -> "John", "user2" -> "Jane") val uuid1 = UUID.randomUUID val uuid2 = UUID.randomUUID - val uuidCodec = HttpCodec.empty.const(UUID.randomUUID()).examples(uuid1, uuid2) + val uuidCodec = HttpCodec.empty.const(UUID.randomUUID()).examples("userId" -> uuid1, "userId2" -> uuid2) val userExamples = userCodec.examples val uuidExamples = uuidCodec.examples assertTrue( - userExamples == Chunk("John", "Jane"), - uuidExamples == Chunk(uuid1, uuid2), + userExamples == Map("user" -> "John", "user2" -> "Jane"), + uuidExamples == Map("userId" -> uuid1, "userId2" -> uuid2), ) } }, ) + } 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 9d68788070..e6df74ed86 100644 --- a/zio-http/src/test/scala/zio/http/endpoint/EndpointSpec.scala +++ b/zio-http/src/test/scala/zio/http/endpoint/EndpointSpec.scala @@ -845,22 +845,22 @@ object EndpointSpec extends ZIOSpecDefault { test("add examples to endpoint") { val endpoint = Endpoint(GET / "repos" / string("org")) .out[String] - .examplesIn("zio") - .examplesOut("all, zio, repos") + .examplesIn("repo" -> "zio") + .examplesOut("foundRepos" -> "all, zio, repos") val endpoint2 = Endpoint(GET / "repos" / string("org") / string("repo")) .out[String] - .examplesIn(("zio", "http"), ("zio", "zio")) - .examplesOut("zio, http") + .examplesIn("repo and org" -> ("zio", "http"), "other repo and org" -> ("zio", "zio")) + .examplesOut("repos" -> "zio, http") val inExamples1 = endpoint.examplesIn val outExamples1 = endpoint.examplesOut val inExamples2 = endpoint2.examplesIn val outExamples2 = endpoint2.examplesOut assertTrue( - inExamples1 == Chunk("zio"), - outExamples1 == Chunk("all, zio, repos"), - inExamples2 == Chunk(("zio", "http"), ("zio", "zio")), - outExamples2 == Chunk("zio, http"), + inExamples1 == Map("repo" -> "zio"), + outExamples1 == Map("foundRepos" -> "all, zio, repos"), + inExamples2 == Map("repo and org" -> ("zio", "http"), "other repo and org" -> ("zio", "zio")), + outExamples2 == Map("repos" -> "zio, http"), ) } },