From 75afb82c894af302866e0866293eb330d6ba4915 Mon Sep 17 00:00:00 2001 From: Eshu Date: Fri, 13 Oct 2023 00:25:52 +0900 Subject: [PATCH] Support query parameters with multiple values --- .../zio/http/endpoint/cli/CliEndpoint.scala | 16 +-- .../zio/http/endpoint/cli/HttpOptions.scala | 5 +- .../scala/zio/http/endpoint/cli/AuxGen.scala | 11 ++ .../zio/http/endpoint/cli/CommandGen.scala | 10 +- .../zio/http/endpoint/cli/EndpointGen.scala | 4 +- .../zio/http/endpoint/cli/OptionsGen.scala | 8 +- .../src/main/scala/example/CliExamples.scala | 2 +- .../scala/example/CombinerTypesExample.scala | 4 +- .../main/scala/zio/http/codec/HttpCodec.scala | 15 +- .../scala/zio/http/codec/HttpCodecError.scala | 25 ++-- .../scala/zio/http/codec/QueryCodecs.scala | 31 ++--- .../scala/zio/http/codec/TextChunkCodec.scala | 79 +++++++++++ .../http/codec/internal/AtomizedCodecs.scala | 4 +- .../http/codec/internal/EncoderDecoder.scala | 24 ++-- .../scala/zio/http/codec/HttpCodecSpec.scala | 131 +++++++++++++++--- .../zio/http/codec/TextChunkCodecTest.scala | 91 ++++++++++++ .../http/endpoint/QueryParameterSpec.scala | 56 +++++++- .../scala/zio/http/endpoint/RequestSpec.scala | 19 +++ 18 files changed, 437 insertions(+), 98 deletions(-) create mode 100644 zio-http/src/main/scala/zio/http/codec/TextChunkCodec.scala create mode 100644 zio-http/src/test/scala/zio/http/codec/TextChunkCodecTest.scala 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 824c3d3b3b..3dc9dd1a95 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 @@ -1,15 +1,7 @@ package zio.http.endpoint.cli -import scala.util.Try - -import zio.cli._ - -import zio.schema._ - import zio.http._ -import zio.http.codec.HttpCodec.Metadata import zio.http.codec._ -import zio.http.codec.internal._ import zio.http.endpoint._ /** @@ -133,10 +125,10 @@ private[cli] object CliEndpoint { case HttpCodec.Path(pathCodec, _) => CliEndpoint(url = HttpOptions.Path(pathCodec) :: List()) - case HttpCodec.Query(name, textCodec, _) => - textCodec.asInstanceOf[TextCodec[_]] match { - case TextCodec.Constant(value) => CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: List()) - case _ => CliEndpoint(url = HttpOptions.Query(name, textCodec) :: List()) + case query: HttpCodec.Query[Input, ?] => + query.codec.parent match { + case TextCodec.Constant(value) => CliEndpoint(url = HttpOptions.QueryConstant(query.name, value) :: List()) + case _ => CliEndpoint(url = HttpOptions.Query(query.name, query.codec) :: List()) } case HttpCodec.Status(_, _) => CliEndpoint.empty diff --git a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala index e7f393b456..e260f4d163 100644 --- a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala +++ b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala @@ -263,11 +263,12 @@ private[cli] object HttpOptions { } - final case class Query(override val name: String, textCodec: TextCodec[_], doc: Doc = Doc.empty) extends URLOptions { + final case class Query(override val name: String, codec: TextChunkCodec[_, _], doc: Doc = Doc.empty) + extends URLOptions { self => override val tag = "?" + name - lazy val options: Options[_] = optionsFromTextCodec(textCodec)(name) + lazy val options: Options[_] = optionsFromTextCodec(codec.parent)(name) override def ??(doc: Doc): Query = self.copy(doc = self.doc + doc) diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/AuxGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/AuxGen.scala index 8fdb42d863..e34f3830f6 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/AuxGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/AuxGen.scala @@ -22,6 +22,17 @@ object AuxGen { Gen.alphaNumericStringBounded(1, 30).map(TextCodec.constant(_)), ) + lazy val anyTextChunkCodec: Gen[Any, TextChunkCodec[_, _]] = Gen.oneOf( + Gen.fromIterable( + List( + TextChunkCodec.any[Any] _, + TextChunkCodec.oneOrMore[Any] _, + TextChunkCodec.optional[Any] _, + TextChunkCodec.one[Any] _, + ), + ), + ) zip anyTextCodec map { case (tcc, tc) => tcc(tc.erase) } + lazy val anyMediaType: Gen[Any, MediaType] = Gen.fromIterable(MediaType.allMediaTypes) lazy val anyDoc: Gen[Any, Doc] = Gen.alphaNumericStringBounded(1, 30).map(Doc.p(_)) diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala index 4b61db919e..294b57448a 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala @@ -1,6 +1,5 @@ package zio.http.endpoint.cli -import zio.ZNothing import zio.cli._ import zio.test._ @@ -9,7 +8,6 @@ import zio.schema._ import zio.http._ import zio.http.codec._ import zio.http.endpoint._ -import zio.http.endpoint.cli.AuxGen._ import zio.http.endpoint.cli.CliRepr.HelpRepr import zio.http.endpoint.cli.EndpointGen._ @@ -46,7 +44,7 @@ object CommandGen { case _: HttpOptions.Constant => false case _ => true }.map { - case HttpOptions.Path(pathCodec, _) => + case HttpOptions.Path(pathCodec, _) => pathCodec.segments.toList.flatMap { case segment => getSegment(segment) match { case (_, "") => Nil @@ -54,12 +52,12 @@ object CommandGen { case (name, codec) => s"${getName(name, "")} $codec" :: Nil } } - case HttpOptions.Query(name, textCodec, _) => - getType(textCodec) match { + case HttpOptions.Query(name, codec, _) => + getType(codec.parent) match { case "" => s"[${getName(name, "")}]" :: Nil case codec => s"${getName(name, "")} $codec" :: Nil } - case _ => Nil + case _ => Nil }.foldRight(List[String]())(_ ++ _) val headersOptions = cliEndpoint.headers.filter { diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala index 5c7ba70f94..26970e2345 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala @@ -91,10 +91,10 @@ object EndpointGen { } lazy val anyQuery: Gen[Any, CliReprOf[Codec[_]]] = - Gen.alphaNumericStringBounded(1, 30).zip(anyTextCodec).map { case (name, codec) => + Gen.alphaNumericStringBounded(1, 30).zip(anyTextChunkCodec).map { case (name, codec) => CliRepr( HttpCodec.Query(name, codec), - codec match { + codec.parent match { case TextCodec.Constant(value) => CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: Nil) case _ => CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil) }, diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala index cdaea7e1c1..d1985752fe 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala @@ -76,16 +76,16 @@ object OptionsGen { }, Gen .alphaNumericStringBounded(1, 30) - .zip(anyTextCodec) + .zip(anyTextChunkCodec) .map { - case (name, TextCodec.Constant(value)) => + case (name, TextChunkCodec(TextCodec.Constant(value))) => CliRepr( Options.Empty.map(_ => value), CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: Nil), ) - case (name, codec) => + case (name, codec) => CliRepr( - encodeOptions(name, codec), + encodeOptions(name, codec.parent), CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil), ) }, diff --git a/zio-http-example/src/main/scala/example/CliExamples.scala b/zio-http-example/src/main/scala/example/CliExamples.scala index 3b5032b3fa..13d9899910 100644 --- a/zio-http-example/src/main/scala/example/CliExamples.scala +++ b/zio-http-example/src/main/scala/example/CliExamples.scala @@ -50,7 +50,7 @@ trait TestCliEndpoints { "posts" / int("postId") ?? Doc.p("The unique identifier of the post"), ) .query( - paramStr("user-name") ?? Doc.p( + query("user-name") ?? Doc.p( "The user's name", ), ) diff --git a/zio-http-example/src/main/scala/example/CombinerTypesExample.scala b/zio-http-example/src/main/scala/example/CombinerTypesExample.scala index e1546e18ac..47a2d05300 100644 --- a/zio-http-example/src/main/scala/example/CombinerTypesExample.scala +++ b/zio-http-example/src/main/scala/example/CombinerTypesExample.scala @@ -5,8 +5,8 @@ import zio.http.codec._ object CombinerTypesExample extends App { - val foo = paramStr("foo") - val bar = paramStr("bar") + val foo = query("foo") + val bar = query("bar") val combine1L1R: HttpCodec[HttpCodecType.Query, (String, String)] = foo & bar val combine1L2R: HttpCodec[HttpCodecType.Query, (String, String, String)] = foo & (bar & bar) 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 6e49a16009..6dc9d49a83 100644 --- a/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala @@ -22,6 +22,7 @@ import scala.language.implicitConversions import scala.reflect.ClassTag import zio._ +import zio.prelude._ import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.stream.ZStream @@ -578,14 +579,18 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with def index(index: Int): ContentStream[A] = copy(index = index) } - private[http] final case class Query[A](name: String, textCodec: TextCodec[A], index: Int = 0) - extends Atom[HttpCodecType.Query, A] { - self => - def erase: Query[Any] = self.asInstanceOf[Query[Any]] + + private[http] case class Query[A, I](name: String, codec: TextChunkCodec[A, I], index: Int = 0) + extends Atom[HttpCodecType.Query, A] { + def erase: Query[Any, I] = asInstanceOf[Query[Any, I]] def tag: AtomTag = AtomTag.Query - def index(index: Int): Query[A] = copy(index = index) + def index(index: Int): Query[A, I] = copy(index = index) + + def encode(value: A): Chunk[String] = codec.encode(value) + + def decode(chunk: Chunk[String]): TextChunkCodec.DecodeResult[A] = codec.decode(chunk) } private[http] final case class Method[A](codec: SimpleCodec[zio.http.Method, A], index: Int = 0) diff --git a/zio-http/src/main/scala/zio/http/codec/HttpCodecError.scala b/zio-http/src/main/scala/zio/http/codec/HttpCodecError.scala index b49bacf1d3..b5e19d7b1f 100644 --- a/zio-http/src/main/scala/zio/http/codec/HttpCodecError.scala +++ b/zio-http/src/main/scala/zio/http/codec/HttpCodecError.scala @@ -30,30 +30,39 @@ sealed trait HttpCodecError extends Exception with NoStackTrace { def message: String } object HttpCodecError { - final case class MissingHeader(headerName: String) extends HttpCodecError { + final case class MissingHeader(headerName: String) extends HttpCodecError { def message = s"Missing header $headerName" } - final case class MalformedMethod(expected: zio.http.Method, actual: zio.http.Method) extends HttpCodecError { + final case class MalformedMethod(expected: zio.http.Method, actual: zio.http.Method) extends HttpCodecError { def message = s"Expected $expected but found $actual" } - final case class PathTooShort(path: Path, textCodec: TextCodec[_]) extends HttpCodecError { + final case class PathTooShort(path: Path, textCodec: TextCodec[_]) extends HttpCodecError { def message = s"Expected to find ${textCodec} but found pre-mature end to the path ${path}" } - final case class MalformedPath(path: Path, pathCodec: PathCodec[_], error: String) extends HttpCodecError { + final case class MalformedPath(path: Path, pathCodec: PathCodec[_], error: String) extends HttpCodecError { def message = s"Malformed path ${path} failed to decode using $pathCodec: $error" } - final case class MalformedStatus(expected: Status, actual: Status) extends HttpCodecError { + final case class MalformedStatus(expected: Status, actual: Status) extends HttpCodecError { def message = s"Expected status code ${expected} but found ${actual}" } - final case class MalformedHeader(headerName: String, textCodec: TextCodec[_]) extends HttpCodecError { + final case class MalformedHeader(headerName: String, textCodec: TextCodec[_]) extends HttpCodecError { def message = s"Malformed header $headerName failed to decode using $textCodec" } - final case class MissingQueryParam(queryParamName: String) extends HttpCodecError { + final case class MissingQueryParam(queryParamName: String) extends HttpCodecError { def message = s"Missing query parameter $queryParamName" } - final case class MalformedQueryParam(queryParamName: String, textCodec: TextCodec[_]) extends HttpCodecError { + final case class SingleQueryParamValueExpected(queryParamName: String) extends HttpCodecError { + def message = s"Single query parameter $queryParamName value expected, but multiple values are found" + } + final case class MalformedQueryParam(queryParamName: String, textCodec: TextCodec[_]) extends HttpCodecError { def message = s"Malformed query parameter $queryParamName failed to decode using $textCodec" } + + final case class InvalidQueryParamCardinality(queryParamName: String, actual: Int, expected: String) + extends HttpCodecError { + def message = s"Wrong query parameter $queryParamName cardinality $actual, $expected expected" + } + final case class MalformedBody(details: String, cause: Option[Throwable] = None) extends HttpCodecError { def message = s"Malformed request body failed to decode: $details" } 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 5bf72e57e3..18f8c560ac 100644 --- a/zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -15,30 +15,23 @@ */ package zio.http.codec -import zio.stacktracer.TracingImplicits.disableAutoTrace +import zio.{Chunk, NonEmptyChunk} private[codec] trait QueryCodecs { - def query(name: String): QueryCodec[String] = - HttpCodec.Query(name, TextCodec.string) + @inline def queryAs[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = + HttpCodec.Query(name, TextChunkCodec.one(codec)) - def queryBool(name: String): QueryCodec[Boolean] = - HttpCodec.Query(name, TextCodec.boolean) + def query(name: String): QueryCodec[String] = queryAs[String](name) - def queryInt(name: String): QueryCodec[Int] = - HttpCodec.Query(name, TextCodec.int) + def queryBool(name: String): QueryCodec[Boolean] = queryAs[Boolean](name) - def queryAs[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = - HttpCodec.Query(name, codec) + def queryInt(name: String): QueryCodec[Int] = queryAs[Int](name) - def paramStr(name: String): QueryCodec[String] = - HttpCodec.Query(name, TextCodec.string) + def queryOpt[I](name: String)(implicit codec: TextCodec[I]): QueryCodec[Option[I]] = + HttpCodec.Query(name, TextChunkCodec.optional(codec)) - def paramBool(name: String): QueryCodec[Boolean] = - HttpCodec.Query(name, TextCodec.boolean) - - def paramInt(name: String): QueryCodec[Int] = - HttpCodec.Query(name, TextCodec.int) - - def paramAs[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = - HttpCodec.Query(name, codec) + def queryAll[I](name: String)(implicit codec: TextCodec[I]): QueryCodec[Chunk[I]] = + HttpCodec.Query(name, TextChunkCodec.any(codec)) + def queryOneOrMore[I](name: String)(implicit codec: TextCodec[I]): QueryCodec[NonEmptyChunk[I]] = + HttpCodec.Query(name, TextChunkCodec.oneOrMore(codec)) } diff --git a/zio-http/src/main/scala/zio/http/codec/TextChunkCodec.scala b/zio-http/src/main/scala/zio/http/codec/TextChunkCodec.scala new file mode 100644 index 0000000000..1e73d0bd16 --- /dev/null +++ b/zio-http/src/main/scala/zio/http/codec/TextChunkCodec.scala @@ -0,0 +1,79 @@ +package zio.http.codec + +import scala.annotation.tailrec + +import zio.prelude._ +import zio.{Chunk, ChunkBuilder, NonEmptyChunk} + +sealed trait TextChunkCodec[A, I] { + def parent: TextCodec[I] + def decode(chunk: Chunk[String]): TextChunkCodec.DecodeResult[A] + def encode(value: A): Chunk[String] +} + +object TextChunkCodec { + def unapply[I](codec: TextChunkCodec[_, I]): Option[TextCodec[I]] = Some(codec.parent) + + def any[I](codec: TextCodec[I]): TextChunkCodec[Chunk[I], I] = new TextChunkCodec[Chunk[I], I] { + def parent: TextCodec[I] = codec + def decode(chunk: Chunk[String]): DecodeResult[Chunk[I]] = _decode(codec, chunk) + def encode(value: Chunk[I]): Chunk[String] = value map codec.encode + } + def oneOrMore[I](codec: TextCodec[I]): TextChunkCodec[NonEmptyChunk[I], I] = new TextChunkCodec[NonEmptyChunk[I], I] { + def parent: TextCodec[I] = codec + def decode(chunk: Chunk[String]): DecodeResult[NonEmptyChunk[I]] = { + _decode(codec, chunk) flatMap (_.nonEmptyOrElse[DecodeResult[NonEmptyChunk[I]]](MissedData)(DecodeSuccess(_))) + } + + def encode(value: NonEmptyChunk[I]): Chunk[String] = value map codec.encode + } + def optional[I](codec: TextCodec[I]): TextChunkCodec[Option[I], I] = new TextChunkCodec[Option[I], I] { + def parent: TextCodec[I] = codec + override def decode(chunk: Chunk[String]): DecodeResult[Option[I]] = chunk match { + case Chunk(value) => if (codec.isDefinedAt(value)) DecodeSuccess(Some(codec(value))) else MalformedData(codec) + case chunk if chunk.isEmpty => DecodeSuccess(None) + case _ => InvalidCardinality(chunk.length, "one or none") + } + override def encode(value: Option[I]): Chunk[String] = (value map codec.encode).toChunk + } + def one[I](codec: TextCodec[I]): TextChunkCodec[I, I] = new TextChunkCodec[I, I] { + def parent: TextCodec[I] = codec + override def decode(chunk: Chunk[String]): DecodeResult[I] = chunk match { + case Chunk(value) => if (codec.isDefinedAt(value)) DecodeSuccess(codec(value)) else MalformedData(codec) + case chunk if chunk.isEmpty => MissedData + case _ => InvalidCardinality(chunk.length, "exactly one") + } + override def encode(value: I): Chunk[String] = Chunk(codec.encode(value)) + } + + private def _decode[I](codec: TextCodec[I], chunk: Chunk[String]): DecodeResult[Chunk[I]] = { + val decoded = ChunkBuilder.make[I](chunk.length) + + @tailrec def loop(i: Int): DecodeResult[Chunk[I]] = { + if (i < chunk.length) { + val value = chunk(i) + if (codec.isDefinedAt(value)) { + decoded += codec(value) + loop(i + 1) + } else MalformedData(codec) + } else DecodeSuccess(decoded.result) + } + + loop(0) + } + + sealed trait DecodeResult[+A] { + def flatMap[B](f: A => DecodeResult[B]): DecodeResult[B] + } + + case class DecodeSuccess[A](value: A) extends DecodeResult[A] { + def flatMap[B](f: A => DecodeResult[B]): DecodeResult[B] = f(value) + } + + sealed trait DecodeFailure extends DecodeResult[Nothing] { + def flatMap[B](f: Nothing => DecodeResult[B]): DecodeResult[B] = this + } + case object MissedData extends DecodeFailure + final case class InvalidCardinality(actual: Int, expected: String) extends DecodeFailure + final case class MalformedData(codec: TextCodec[_]) extends DecodeFailure +} 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 62fb2424f1..3fc29f74b8 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 @@ -25,7 +25,7 @@ import zio.http.codec._ private[http] final case class AtomizedCodecs( method: Chunk[SimpleCodec[zio.http.Method, _]], path: Chunk[PathCodec[_]], - query: Chunk[Query[_]], + query: Chunk[Query[_, _]], header: Chunk[Header[_]], content: Chunk[BodyCodec[_]], status: Chunk[SimpleCodec[zio.http.Status, _]], @@ -33,7 +33,7 @@ private[http] final case class AtomizedCodecs( def append(atom: Atom[_, _]): AtomizedCodecs = atom match { case path0: Path[_] => self.copy(path = path :+ path0.pathCodec) case method0: Method[_] => self.copy(method = method :+ method0.codec) - case query0: Query[_] => self.copy(query = query :+ query0) + case query0: Query[_, _] => self.copy(query = query :+ query0) case header0: Header[_] => self.copy(header = header :+ header0) case content0: Content[_] => self.copy(content = content :+ BodyCodec.Single(content0.schema, content0.mediaType, content0.name)) diff --git a/zio-http/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala b/zio-http/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala index 6fee2f52d4..61dde60ef7 100644 --- a/zio-http/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala +++ b/zio-http/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala @@ -283,20 +283,14 @@ private[codec] object EncoderDecoder { var i = 0 val queries = flattened.query while (i < queries.length) { - val query = queries(i).erase - - val queryParamValue = - queryParams - .getAllOrElse(query.name, Nil) - .collectFirst(query.textCodec) - - queryParamValue match { - case Some(value) => - inputs(i) = value - case None => - throw HttpCodecError.MissingQueryParam(query.name) + val query = queries(i) + inputs(i) = query.decode(queryParams.getAllOrElse(query.name, Nil)) match { + case TextChunkCodec.DecodeSuccess(value) => value + case TextChunkCodec.MissedData => throw HttpCodecError.MissingQueryParam(query.name) + case TextChunkCodec.MalformedData(codec) => throw HttpCodecError.MalformedQueryParam(query.name, codec) + case TextChunkCodec.InvalidCardinality(actual, expected) => + throw HttpCodecError.InvalidQueryParamCardinality(query.name, actual, expected) } - i = i + 1 } } @@ -482,9 +476,7 @@ private[codec] object EncoderDecoder { val query = flattened.query(i).erase val input = inputs(i) - val value = query.textCodec.encode(input) - - queryParams = queryParams.add(query.name, value) + queryParams = queryParams.addAll(query.name, query.encode(input)) i = i + 1 } 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 0dd63f1376..1a5cb84c49 100644 --- a/zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala +++ b/zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala @@ -19,6 +19,7 @@ package zio.http.codec import java.util.UUID import zio._ +import zio.prelude.Id import zio.test._ import zio.http._ @@ -37,9 +38,25 @@ object HttpCodecSpec extends ZIOHttpSpec { val emptyJson = Body.fromString("{}") - val isAge = "isAge" - val codecBool = QueryCodec.paramBool(isAge) - def makeRequest(paramValue: String) = Request.get(googleUrl.queryParams(QueryParams(isAge -> paramValue))) + private val strParam = "name" + private val codecStr = QueryCodec.query(strParam) + private val boolParam = "isAge" + private val codecBool = QueryCodec.queryBool(boolParam) + private val intParam = "age" + private val codecInt = QueryCodec.queryInt(intParam) + private val longParam = "count" + private val codecLong = QueryCodec.queryAs[Long](longParam) + private val optBoolParam = "maybe" + private val codecOptBool = QueryCodec.queryOpt[Boolean](optBoolParam) + private val seqIntParam = "integers" + private val codecSeqInt = QueryCodec.queryAll[Int](seqIntParam) + private val oneOrMoreStrParam = "names" + private val codecOneOrMoreStr = QueryCodec.queryOneOrMore[String](oneOrMoreStrParam) + + def makeRequest(name: String, value: Any) = + Request.get(googleUrl.queryParams(QueryParams(name -> value.toString))) + def makeChunkRequest(name: String, values: Chunk[Any]) = + Request.get(googleUrl.queryParams(QueryParams(name -> values.map(_.toString)))) def spec = suite("HttpCodecSpec")( suite("fallback") { @@ -144,25 +161,103 @@ object HttpCodecSpec extends ZIOHttpSpec { } } + suite("QueryCodec")( - test("paramBool decoding with case-insensitive") { - assertZIO(codecBool.decodeRequest(makeRequest("true")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("TRUE")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("yes")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("YES")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("on")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("ON")))(Assertion.isTrue) - }, - test("paramBool decoding with different values") { - assertZIO(codecBool.decodeRequest(makeRequest("true")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("1")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("yes")))(Assertion.isTrue) && - assertZIO(codecBool.decodeRequest(makeRequest("on")))(Assertion.isTrue) + test("paramStr decoding and encoding") { + check(Gen.alphaNumericString) { value => + assertZIO(codecStr.decodeRequest(makeRequest(strParam, value)))(Assertion.equalTo(value)) && + assert(codecStr.encodeRequest(value).url.queryParams.get(strParam))( + Assertion.isSome(Assertion.equalTo(value)), + ) + } + }, + test("paramBool decoding true") { + Chunk("true", "TRUE", "yes", "YES", "on", "ON", "1") map { value => + assertZIO(codecBool.decodeRequest(makeRequest(boolParam, value)))(Assertion.isTrue) + } reduce (_ && _) + }, + test("paramBool decoding false") { + Chunk("false", "FALSE", "no", "NO", "off", "OFF", "0") map { value => + assertZIO(codecBool.decodeRequest(makeRequest(boolParam, value)))(Assertion.isFalse) + } reduce (_ && _) }, test("paramBool encoding") { val requestTrue = codecBool.encodeRequest(true) val requestFalse = codecBool.encodeRequest(false) - assert(requestTrue.url.queryParams.get(isAge).get)(Assertion.equalTo("true")) && - assert(requestFalse.url.queryParams.get(isAge).get)(Assertion.equalTo("false")) + assert(requestTrue.url.queryParams.get(boolParam).get)(Assertion.equalTo("true")) && + assert(requestFalse.url.queryParams.get(boolParam).get)(Assertion.equalTo("false")) + }, + test("paramInt decoding and encoding") { + check(Gen.int) { value => + assertZIO(codecInt.decodeRequest(makeRequest(intParam, value)))(Assertion.equalTo(value)) && + assert(codecInt.encodeRequest(value).url.queryParams.get(intParam))( + Assertion.isSome(Assertion.equalTo(value.toString)), + ) + } + }, + test("paramLong decoding and encoding") { + check(Gen.long) { value => + assertZIO(codecLong.decodeRequest(makeRequest(longParam, value)))(Assertion.equalTo(value)) && + assert(codecLong.encodeRequest(value).url.queryParams.get(longParam))( + Assertion.isSome(Assertion.equalTo(value.toString)), + ) + } + }, + test("paramOpt decoding empty chunk") { + assertZIO(codecOptBool.decodeRequest(makeChunkRequest(optBoolParam, Chunk.empty)))(Assertion.isNone) + }, + test("paramOpt decoding singleton chunk") { + assertZIO(codecOptBool.decodeRequest(makeChunkRequest(optBoolParam, Chunk("true"))))( + Assertion.isSome(Assertion.isTrue), + ) && + assertZIO(codecOptBool.decodeRequest(makeChunkRequest(optBoolParam, Chunk("false"))))( + Assertion.isSome(Assertion.isFalse), + ) + }, + test("paramOpt encoding empty chunk") { + assert(codecOptBool.encodeRequest(None).url.queryParams.get(optBoolParam))(Assertion.isNone) + }, + test("paramOpt encoding non-empty chunk") { + assert(codecOptBool.encodeRequest(Some(true)).url.queryParams.getAll(optBoolParam).get)( + Assertion.equalTo(Chunk("true")), + ) && + assert(codecOptBool.encodeRequest(Some(false)).url.queryParams.getAll(optBoolParam).get)( + Assertion.equalTo(Chunk("false")), + ) + }, + test("params decoding empty chunk") { + assertZIO(codecSeqInt.decodeRequest(makeChunkRequest(seqIntParam, Chunk.empty)))(Assertion.isEmpty) + }, + test("params decoding non-empty chunk") { + assertZIO(codecSeqInt.decodeRequest(makeChunkRequest(seqIntParam, Chunk("2023", "10", "7"))))( + Assertion.equalTo(Chunk(2023, 10, 7)), + ) + }, + test("params encoding empty chunk") { + assert(codecSeqInt.encodeRequest(Chunk.empty).url.queryParams.get(seqIntParam))(Assertion.isNone) + }, + test("params encoding non-empty chunk") { + assert(codecSeqInt.encodeRequest(Chunk(1974, 5, 3)).url.queryParams.getAll(seqIntParam).get)( + Assertion.equalTo(Chunk("1974", "5", "3")), + ) + }, + test("paramOneOrMore decoding non-empty chunk") { + assertZIO(codecOneOrMoreStr.decodeRequest(makeChunkRequest(oneOrMoreStrParam, Chunk("one"))))( + Assertion.equalTo(NonEmptyChunk("one")), + ) && + assertZIO(codecOneOrMoreStr.decodeRequest(makeChunkRequest(oneOrMoreStrParam, Chunk("one", "two", "three"))))( + Assertion.equalTo(NonEmptyChunk("one", "two", "three")), + ) + }, + test("paramOneOrMore encoding non-empty chunk") { + assert( + codecOneOrMoreStr + .encodeRequest(NonEmptyChunk("for", "five", "six")) + .url + .queryParams + .getAll(oneOrMoreStrParam) + .get, + )( + Assertion.equalTo(Chunk("for", "five", "six")), + ) }, ) + suite("Codec with examples") { diff --git a/zio-http/src/test/scala/zio/http/codec/TextChunkCodecTest.scala b/zio-http/src/test/scala/zio/http/codec/TextChunkCodecTest.scala new file mode 100644 index 0000000000..530d2347a0 --- /dev/null +++ b/zio-http/src/test/scala/zio/http/codec/TextChunkCodecTest.scala @@ -0,0 +1,91 @@ +package zio.http.codec + +import zio._ +import zio.test._ + +object TextChunkCodecTest extends ZIOSpecDefault { + override def spec: Spec[TestEnvironment with Scope, Any] = suite("Text chunk codec") { + suite("success") { + test("one encode and decode") { + val codec = TextChunkCodec.one(TextCodec.boolean) + assertTrue(codec.decode(Chunk("true")) == TextChunkCodec.DecodeSuccess(true)) + assertTrue(codec.encode(true) == Chunk("true")) + } + + test("one encode and decode") { + val codec = TextChunkCodec.optional(TextCodec.long) + assertTrue(codec.decode(Chunk.empty) == TextChunkCodec.DecodeSuccess(None)) + assertTrue(codec.encode(None) == Chunk.empty) + assertTrue(codec.decode(Chunk("42")) == TextChunkCodec.DecodeSuccess(Some(42L))) + assertTrue(codec.encode(Some(42L)) == Chunk("42")) + } + + test("oneOrMore encode and decode") { + val codec = TextChunkCodec.oneOrMore(TextCodec.int) + assertTrue(codec.decode(Chunk("42")) == TextChunkCodec.DecodeSuccess(NonEmptyChunk(42))) + assertTrue(codec.encode(NonEmptyChunk(42)) == Chunk("42")) + assertTrue(codec.decode(Chunk("1", "2", "3")) == TextChunkCodec.DecodeSuccess(NonEmptyChunk(1, 2, 3))) + assertTrue(codec.encode(NonEmptyChunk(1, 2, 3)) == Chunk("1", "2", "3")) + } + + test("any encode and decode") { + val codec = TextChunkCodec.any(TextCodec.string) + assertTrue(codec.decode(Chunk.empty) == TextChunkCodec.DecodeSuccess(Chunk.empty)) + assertTrue(codec.encode(Chunk.empty) == Chunk.empty) + assertTrue(codec.decode(Chunk("Elm")) == TextChunkCodec.DecodeSuccess(Chunk("Elm"))) + assertTrue(codec.encode(Chunk("Street")) == Chunk("Street")) + assertTrue( + codec.decode(Chunk("One", "Two", "Freddy's Coming For You")) == + TextChunkCodec.DecodeSuccess(Chunk("One", "Two", "Freddy's Coming For You")), + ) + assertTrue( + codec.encode(Chunk("Three", "Four", "Better Lock Your Door")) == + Chunk("Three", "Four", "Better Lock Your Door"), + ) + } + } + + suite("failure") { + suite("malformed data") { + test("one decode") { + assertTrue( + TextChunkCodec.one(TextCodec.boolean).decode(Chunk("")) == TextChunkCodec.MalformedData(TextCodec.boolean), + ) + } + + test("optional decode") { + assertTrue( + TextChunkCodec.optional(TextCodec.int).decode(Chunk("abc")) == + TextChunkCodec.MalformedData(TextCodec.int), + ) + } + + test("oneOrMore decode") { + val codec = TextChunkCodec.oneOrMore(TextCodec.long) + assertTrue(codec.decode(Chunk("#$@")) == TextChunkCodec.MalformedData(TextCodec.long)) + assertTrue(codec.decode(Chunk("123", "#$@", "567")) == TextChunkCodec.MalformedData(TextCodec.long)) + } + + test("any decode") { + val codec = TextChunkCodec.any(TextCodec.uuid) + assertTrue(codec.decode(Chunk("42")) == TextChunkCodec.MalformedData(TextCodec.uuid)) + assertTrue( + codec.decode(Chunk("7", "00000000-feed-dada-iced-c0ffee000000", "3")) == + TextChunkCodec.MalformedData(TextCodec.uuid), + ) + } + } + + suite("invalid cardinality") { + test("one decode") { + val codec = TextChunkCodec.one(TextCodec.string) + assertTrue(codec.decode(Chunk.empty) == TextChunkCodec.MissedData) + assertTrue(codec.decode(Chunk("a", "b", "c")) == TextChunkCodec.InvalidCardinality(3, "exactly one")) + } + + test("optional decode") { + assertTrue( + TextChunkCodec.optional(TextCodec.string).decode(Chunk("x", "y")) == + TextChunkCodec.InvalidCardinality(2, "one or none"), + ) + } + + test("oneOrMore decode") { + assertTrue( + TextChunkCodec.oneOrMore(TextCodec.string).decode(Chunk.empty) == TextChunkCodec.MissedData, + ) + } + } + } + } +} diff --git a/zio-http/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index 165a1adbec..211a9c0f5b 100644 --- a/zio-http/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala @@ -30,7 +30,7 @@ import zio.schema.{DeriveSchema, Schema} import zio.http.Header.ContentType import zio.http.Method._ import zio.http._ -import zio.http.codec.HttpCodec.{query, queryInt} +import zio.http.codec.HttpCodec._ import zio.http.codec._ import zio.http.endpoint.EndpointSpec.testEndpoint import zio.http.forms.Fixtures.formField @@ -105,5 +105,59 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes(s"/users/$userId?key=$key&value=$value", s"path(users, $userId, Some($key), Some($value))") } }, + test("query parameter with any number of values") { + check(Gen.boolean, Gen.alphaNumericString, Gen.alphaNumericString) { (isSomething, name1, name2) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "data") + .query(queryAs[Boolean]("isSomething")) + .query(queryAll[String]("name")) + .out[String] + .implement { + Handler.fromFunction { case (isSomething, names) => + s"query($isSomething, ${names mkString ", "})" + } + }, + ), + ) _ + testRoutes(s"/data?isSomething=$isSomething", s"query($isSomething, )") && + testRoutes(s"/data?isSomething=$isSomething&name=$name1", s"query($isSomething, $name1)") && + testRoutes(s"/data?isSomething=$isSomething&name=$name1&name=$name2", s"query($isSomething, $name1, $name2)") + } + }, + test("query parameter with one or more values") { + check(Gen.boolean, Gen.alphaNumericString, Gen.alphaNumericString) { (isSomething, name1, name2) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "data") + .query(queryAs[Boolean]("isSomething")) + .query(queryOneOrMore[String]("name")) + .out[String] + .implement { + Handler.fromFunction { case (isSomething, names) => + s"query($isSomething, ${names mkString ", "})" + } + }, + ), + ) _ + testRoutes(s"/data?isSomething=$isSomething&name=$name1", s"query($isSomething, $name1)") && + testRoutes(s"/data?isSomething=$isSomething&name=$name1&name=$name2", s"query($isSomething, $name1, $name2)") + } + }, + test("query parameter with optional value") { + check(Gen.alphaNumericString) { name => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "data") + .query(queryOpt[String]("name")) + .out[String] + .implement { + Handler.fromFunction { name => s"query($name)" } + }, + ), + ) _ + testRoutes(s"/data", s"query(None)") && testRoutes(s"/data?name=$name", s"query(Some($name))") + } + }, ) } diff --git a/zio-http/src/test/scala/zio/http/endpoint/RequestSpec.scala b/zio-http/src/test/scala/zio/http/endpoint/RequestSpec.scala index c73d2048dc..3e701a3deb 100644 --- a/zio-http/src/test/scala/zio/http/endpoint/RequestSpec.scala +++ b/zio-http/src/test/scala/zio/http/endpoint/RequestSpec.scala @@ -134,6 +134,25 @@ object RequestSpec extends ZIOHttpSpec { assertTrue(contentType.isEmpty) } }, + test("multiple parameters for a single value query") { + check(Gen.int, Gen.int, Gen.int) { (id, id1, id2) => + val endpoint = + Endpoint(GET / "posts") + .query(queryInt("id")) + .out[Int] + val routes = + endpoint.implement { + Handler.succeed(id) + } + for { + response <- routes.toHttpApp.runZIO( + Request.get(URL.decode(s"/posts?id=$id1&id=$id2").toOption.get), + ) + contentType = response.header(Header.ContentType) + } yield assertTrue(extractStatus(response).code == 400) && + assertTrue(contentType.isEmpty) + } + }, test("header codec") { check(Gen.int, Gen.alphaNumericString) { (id, notACorrelationId) => val endpoint =