From ee5182d543e6abe4d70bf34194d58e69f30063f3 Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Thu, 18 Jan 2024 09:42:25 +0100 Subject: [PATCH 1/9] Support for multi value QueryCodecs QueryCodec now retuns NonEmptyChunk[A] instead of just A Single value codecs that return A are derived from it --- .../http/endpoint/QueryParameterSpec.scala | 195 +++++++++++++++++- .../main/scala/zio/http/codec/HttpCodec.scala | 2 +- .../scala/zio/http/codec/QueryCodecs.scala | 28 ++- .../http/codec/internal/EncoderDecoder.scala | 19 +- 4 files changed, 229 insertions(+), 15 deletions(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index 165a1adbec..7b8d479bea 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/jvm/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.{query, queryInt, queryMultiValue, queryMultiValueBool} import zio.http.codec._ import zio.http.endpoint.EndpointSpec.testEndpoint import zio.http.forms.Fixtures.formField @@ -105,5 +105,198 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes(s"/users/$userId?key=$key&value=$value", s"path(users, $userId, Some($key), Some($value))") } }, + test("query parameters with multiple values") { + check(Gen.int, Gen.listOfN(3)(Gen.alphaNumericString)) { (userId, keys) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("key")) + .out[String] + .implement { + Handler.fromFunction { case (userId, keys) => + s"""path(users, $userId, ${keys.mkString(", ")})""" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?key=${keys(0)}&key=${keys(1)}&key=${keys(2)}", + s"path(users, $userId, ${keys(0)}, ${keys(1)}, ${keys(2)})", + ) && + testRoutes( + s"/users/$userId?key=${keys(0)}&key=${keys(1)}", + s"path(users, $userId, ${keys(0)}, ${keys(1)})", + ) && + testRoutes( + s"/users/$userId?key=${keys(0)}", + s"path(users, $userId, ${keys(0)})", + ) + } + }, + test("optional query parameters with multiple values") { + check(Gen.int, Gen.listOfN(3)(Gen.alphaNumericString)) { (userId, keys) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("key").optional) + .out[String] + .implement { + Handler.fromFunction { case (userId, keys) => + s"""path(users, $userId, $keys)""" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?key=${keys(0)}&key=${keys(1)}&key=${keys(2)}", + s"path(users, $userId, Some(${NonEmptyChunk.fromIterable(keys.head, keys.tail)}))", + ) && + testRoutes( + s"/users/$userId", + s"path(users, $userId, None)", + ) && + testRoutes( + s"/users/$userId?key=", + s"path(users, $userId, Some(NonEmptyChunk()))", + ) + } + }, + test("multiple query parameters with multiple values") { + check(Gen.int, Gen.listOfN(3)(Gen.alphaNumericString), Gen.listOfN(2)(Gen.alphaNumericString)) { + (userId, keys, values) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("key") & queryMultiValue("value")) + .out[String] + .implement { + Handler.fromFunction { case (userId, keys, values) => + s"""path(users, $userId, $keys, $values)""" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?key=${keys(0)}&key=${keys(1)}&key=${keys(2)}&value=${values(0)}&value=${values(1)}", + s"path(users, $userId, NonEmptyChunk(${keys(0)}, ${keys(1)}, ${keys(2)}), NonEmptyChunk(${values(0)}, ${values(1)}))", + ) && + testRoutes( + s"/users/$userId?key=${keys(0)}&key=${keys(1)}&value=${values(0)}", + s"path(users, $userId, NonEmptyChunk(${keys(0)}, ${keys(1)}), NonEmptyChunk(${values(0)}))", + ) + } + }, + test("mix of multi value and single value query parameters") { + check(Gen.int, Gen.listOfN(2)(Gen.alphaNumericString), Gen.alphaNumericString) { (userId, multi, single) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("multi") & query("single")) + .out[String] + .implement { + Handler.fromFunction { case (userId, multi, single) => + s"""path(users, $userId, $multi, $single)""" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?multi=${multi(0)}&multi=${multi(1)}&single=$single", + s"path(users, $userId, NonEmptyChunk(${multi(0)}, ${multi(1)}), $single)", + ) + } + }, + test("either of two multi value query parameters") { + check(Gen.int, Gen.listOfN(2)(Gen.alphaNumericString), Gen.listOfN(2)(Gen.boolean)) { (userId, left, right) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("left") | queryMultiValueBool("right")) + .out[String] + .implement { + Handler.fromFunction { case (userId, eitherOfParameters) => + s"path(users, $userId, $eitherOfParameters)" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?left=${left(0)}&left=${left(1)}", + s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + ) && + testRoutes( + s"/users/$userId?right=${right(0)}&right=${right(1)}", + s"path(users, $userId, Right(NonEmptyChunk(${right(0)}, ${right(1)})))", + ) && + testRoutes( + s"/users/$userId?right=${right(0)}&right=${right(1)}&left=${left(0)}&left=${left(1)}", + s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + ) + } + }, + test("either of two multi value query parameters of the same type") { + check(Gen.int, Gen.listOfN(2)(Gen.alphaNumericString), Gen.listOfN(2)(Gen.alphaNumericString)) { + (userId, left, right) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("left") | queryMultiValue("right")) + .out[String] + .implement { + Handler.fromFunction { case (userId, queryParams) => + s"path(users, $userId, $queryParams)" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?left=${left(0)}&left=${left(1)}", + s"path(users, $userId, NonEmptyChunk(${left(0)}, ${left(1)}))", + ) && + testRoutes( + s"/users/$userId?right=${right(0)}&right=${right(1)}", + s"path(users, $userId, NonEmptyChunk(${right(0)}, ${right(1)}))", + ) && + testRoutes( + s"/users/$userId?right=${right(0)}&right=${right(1)}&left=${left(0)}&left=${left(1)}", + s"path(users, $userId, NonEmptyChunk(${left(0)}, ${left(1)}))", + ) + } + }, + test("either of multi value or single value query parameter") { + check(Gen.int, Gen.listOfN(2)(Gen.alphaNumericString), Gen.alphaNumericString) { (userId, left, right) => + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users" / int("userId")) + .query(queryMultiValue("left") | query("right")) + .out[String] + .implement { + Handler.fromFunction { case (userId, queryParams) => + s"path(users, $userId, $queryParams)" + } + }, + ), + ) _ + + testRoutes( + s"/users/$userId?left=${left(0)}&left=${left(1)}", + s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + ) && + testRoutes( + s"/users/$userId?right=$right", + s"path(users, $userId, Right($right))", + ) && + testRoutes( + s"/users/$userId?right=$right&left=${left(0)}&left=${left(1)}", + s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + ) + } + }, ) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index d7084fd658..ef06446048 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -591,7 +591,7 @@ 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] { + extends Atom[HttpCodecType.Query, NonEmptyChunk[A]] { self => def erase: Query[Any] = self.asInstanceOf[Query[Any]] diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index cf8db055dd..63ebd0a4ff 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -15,19 +15,41 @@ */ package zio.http.codec +import zio.NonEmptyChunk import zio.stacktracer.TracingImplicits.disableAutoTrace private[codec] trait QueryCodecs { + def query(name: String): QueryCodec[String] = - HttpCodec.Query(name, TextCodec.string) + HttpCodec + .Query(name, TextCodec.string) + .transform[String] { c: NonEmptyChunk[String] => c.head }(s => NonEmptyChunk(s)) def queryBool(name: String): QueryCodec[Boolean] = - HttpCodec.Query(name, TextCodec.boolean) + HttpCodec + .Query(name, TextCodec.boolean) + .transform { c: NonEmptyChunk[Boolean] => c.head }(s => NonEmptyChunk(s)) def queryInt(name: String): QueryCodec[Int] = - HttpCodec.Query(name, TextCodec.int) + HttpCodec + .Query(name, TextCodec.int) + .transform { c: NonEmptyChunk[Int] => c.head }(s => NonEmptyChunk(s)) def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = + HttpCodec + .Query(name, codec) + .transform { c: NonEmptyChunk[A] => c.head }(s => NonEmptyChunk(s)) + + def queryMultiValue(name: String): QueryCodec[NonEmptyChunk[String]] = + HttpCodec.Query(name, TextCodec.string) + + def queryMultiValueBool(name: String): QueryCodec[NonEmptyChunk[Boolean]] = + HttpCodec.Query(name, TextCodec.boolean) + + def queryMultiValueInt(name: String): QueryCodec[NonEmptyChunk[Int]] = + HttpCodec.Query(name, TextCodec.int) + + def queryMultiValueTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[NonEmptyChunk[A]] = HttpCodec.Query(name, codec) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala index cfb458adb3..59c350bd24 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala @@ -315,17 +315,13 @@ private[codec] object EncoderDecoder { while (i < queries.length) { val query = queries(i).erase - val queryParamValue = + val queryParamValues = queryParams .getAllOrElse(query.name, Nil) - .collectFirst(query.textCodec) + .collect(query.textCodec) - queryParamValue match { - case Some(value) => - inputs(i) = value - case None => - throw HttpCodecError.MissingQueryParam(query.name) - } + if (queryParamValues.size > 0) inputs(i) = NonEmptyChunk.fromChunk(queryParamValues).get + else throw HttpCodecError.MissingQueryParam(query.name) i = i + 1 } @@ -511,9 +507,12 @@ private[codec] object EncoderDecoder { val query = flattened.query(i).erase val input = inputs(i) - val value = query.textCodec.encode(input) + val inputCoerced = input.asInstanceOf[NonEmptyChunk[Any]] - queryParams = queryParams.add(query.name, value) + inputCoerced.foreach { in => + val value = query.textCodec.encode(in) + queryParams = queryParams.add(query.name, value) + } i = i + 1 } From e1ff3bdbf8e4dc7baaa84c7df873cd079399d803 Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Thu, 18 Jan 2024 16:28:04 +0100 Subject: [PATCH 2/9] format --- zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index ef06446048..53d04790e9 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -553,7 +553,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with } private[http] final case class Status[A](codec: SimpleCodec[zio.http.Status, A], index: Int = 0) - extends Atom[HttpCodecType.Status, A] { + extends Atom[HttpCodecType.Status, A] { self => def erase: Status[Any] = self.asInstanceOf[Status[Any]] From 196b3da39079bad8061d0518e94bbe792c2b2d3a Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Thu, 18 Jan 2024 17:02:38 +0100 Subject: [PATCH 3/9] Add parentheses to lambdas --- .../src/main/scala/zio/http/codec/QueryCodecs.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index 63ebd0a4ff..b573bb3c37 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -23,22 +23,22 @@ private[codec] trait QueryCodecs { def query(name: String): QueryCodec[String] = HttpCodec .Query(name, TextCodec.string) - .transform[String] { c: NonEmptyChunk[String] => c.head }(s => NonEmptyChunk(s)) + .transform[String] { (c: NonEmptyChunk[String]) => c.head }(s => NonEmptyChunk(s)) def queryBool(name: String): QueryCodec[Boolean] = HttpCodec .Query(name, TextCodec.boolean) - .transform { c: NonEmptyChunk[Boolean] => c.head }(s => NonEmptyChunk(s)) + .transform { (c: NonEmptyChunk[Boolean]) => c.head }(s => NonEmptyChunk(s)) def queryInt(name: String): QueryCodec[Int] = HttpCodec .Query(name, TextCodec.int) - .transform { c: NonEmptyChunk[Int] => c.head }(s => NonEmptyChunk(s)) + .transform { (c: NonEmptyChunk[Int]) => c.head }(s => NonEmptyChunk(s)) def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = HttpCodec .Query(name, codec) - .transform { c: NonEmptyChunk[A] => c.head }(s => NonEmptyChunk(s)) + .transform { (c: NonEmptyChunk[A]) => c.head }(s => NonEmptyChunk(s)) def queryMultiValue(name: String): QueryCodec[NonEmptyChunk[String]] = HttpCodec.Query(name, TextCodec.string) From ccf91fcf252c6128290f302e3bdfdc0a4052990f Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Thu, 18 Jan 2024 17:14:21 +0100 Subject: [PATCH 4/9] Refactor --- .../scala/zio/http/codec/QueryCodecs.scala | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index b573bb3c37..8a9b7ec77f 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -21,24 +21,16 @@ import zio.stacktracer.TracingImplicits.disableAutoTrace private[codec] trait QueryCodecs { def query(name: String): QueryCodec[String] = - HttpCodec - .Query(name, TextCodec.string) - .transform[String] { (c: NonEmptyChunk[String]) => c.head }(s => NonEmptyChunk(s)) + toSingleValue(HttpCodec.Query(name, TextCodec.string)) def queryBool(name: String): QueryCodec[Boolean] = - HttpCodec - .Query(name, TextCodec.boolean) - .transform { (c: NonEmptyChunk[Boolean]) => c.head }(s => NonEmptyChunk(s)) + toSingleValue(HttpCodec.Query(name, TextCodec.boolean)) def queryInt(name: String): QueryCodec[Int] = - HttpCodec - .Query(name, TextCodec.int) - .transform { (c: NonEmptyChunk[Int]) => c.head }(s => NonEmptyChunk(s)) + toSingleValue(HttpCodec.Query(name, TextCodec.int)) def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = - HttpCodec - .Query(name, codec) - .transform { (c: NonEmptyChunk[A]) => c.head }(s => NonEmptyChunk(s)) + toSingleValue(HttpCodec.Query(name, codec)) def queryMultiValue(name: String): QueryCodec[NonEmptyChunk[String]] = HttpCodec.Query(name, TextCodec.string) @@ -52,4 +44,7 @@ private[codec] trait QueryCodecs { def queryMultiValueTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[NonEmptyChunk[A]] = HttpCodec.Query(name, codec) + private def toSingleValue[A](queryCodec: QueryCodec[NonEmptyChunk[A]]): QueryCodec[A] = + queryCodec.transform { (c: NonEmptyChunk[A]) => c.head }(s => NonEmptyChunk(s)) + } From 4ffc6ceed1a78d920935c50a7f30574a0690aef9 Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Sat, 20 Jan 2024 17:04:02 +0100 Subject: [PATCH 5/9] Instead of NonEmptyChunk use Chunk If query string specifies key fo expected query, but not the value, parse it as empty chunk. If no keys are present- throw error --- .../zio/http/endpoint/EndpointSpec.scala | 10 --- .../http/endpoint/QueryParameterSpec.scala | 67 ++++++++++++++----- .../main/scala/zio/http/codec/HttpCodec.scala | 2 +- .../scala/zio/http/codec/QueryCodecs.scala | 14 ++-- .../http/codec/internal/EncoderDecoder.scala | 27 +++++--- 5 files changed, 73 insertions(+), 47 deletions(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/EndpointSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/EndpointSpec.scala index cb72d2ad95..182a18be36 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/EndpointSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/EndpointSpec.scala @@ -21,19 +21,9 @@ import java.time.Instant import zio._ import zio.test._ -import zio.stream.ZStream - -import zio.schema.annotation.validate -import zio.schema.validation.Validation 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._ -import zio.http.endpoint._ -import zio.http.forms.Fixtures.formField object EndpointSpec extends ZIOHttpSpec { def spec = suite("EndpointSpec")() diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index 7b8d479bea..85b0596b0a 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala @@ -17,20 +17,16 @@ package zio.http.endpoint import java.time.Instant - import zio._ import zio.test._ - import zio.stream.ZStream - import zio.schema.annotation.validate import zio.schema.validation.Validation import zio.schema.{DeriveSchema, Schema} - import zio.http.Header.ContentType import zio.http.Method._ import zio.http._ -import zio.http.codec.HttpCodec.{query, queryInt, queryMultiValue, queryMultiValueBool} +import zio.http.codec.HttpCodec.{query, queryInt, queryMultiValue, queryMultiValueBool, queryMultiValueInt} import zio.http.codec._ import zio.http.endpoint.EndpointSpec.testEndpoint import zio.http.forms.Fixtures.formField @@ -151,7 +147,7 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes( s"/users/$userId?key=${keys(0)}&key=${keys(1)}&key=${keys(2)}", - s"path(users, $userId, Some(${NonEmptyChunk.fromIterable(keys.head, keys.tail)}))", + s"path(users, $userId, Some(${Chunk.fromIterable(keys)}))", ) && testRoutes( s"/users/$userId", @@ -159,7 +155,7 @@ object QueryParameterSpec extends ZIOHttpSpec { ) && testRoutes( s"/users/$userId?key=", - s"path(users, $userId, Some(NonEmptyChunk()))", + s"path(users, $userId, Some(${Chunk.empty}))", ) } }, @@ -181,11 +177,11 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes( s"/users/$userId?key=${keys(0)}&key=${keys(1)}&key=${keys(2)}&value=${values(0)}&value=${values(1)}", - s"path(users, $userId, NonEmptyChunk(${keys(0)}, ${keys(1)}, ${keys(2)}), NonEmptyChunk(${values(0)}, ${values(1)}))", + s"path(users, $userId, ${Chunk.fromIterable(keys)}, ${Chunk.fromIterable(values)})", ) && testRoutes( s"/users/$userId?key=${keys(0)}&key=${keys(1)}&value=${values(0)}", - s"path(users, $userId, NonEmptyChunk(${keys(0)}, ${keys(1)}), NonEmptyChunk(${values(0)}))", + s"path(users, $userId, ${Chunk(keys(0), keys(1))}, ${Chunk(values(0))})", ) } }, @@ -206,7 +202,7 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes( s"/users/$userId?multi=${multi(0)}&multi=${multi(1)}&single=$single", - s"path(users, $userId, NonEmptyChunk(${multi(0)}, ${multi(1)}), $single)", + s"path(users, $userId, ${Chunk.fromIterable(multi)}, $single)", ) } }, @@ -227,15 +223,15 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes( s"/users/$userId?left=${left(0)}&left=${left(1)}", - s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + s"path(users, $userId, Left(${Chunk.fromIterable(left)}))", ) && testRoutes( s"/users/$userId?right=${right(0)}&right=${right(1)}", - s"path(users, $userId, Right(NonEmptyChunk(${right(0)}, ${right(1)})))", + s"path(users, $userId, Right(${Chunk.fromIterable(right)}))", ) && testRoutes( s"/users/$userId?right=${right(0)}&right=${right(1)}&left=${left(0)}&left=${left(1)}", - s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + s"path(users, $userId, Left(${Chunk.fromIterable(left)}))", ) } }, @@ -257,15 +253,15 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes( s"/users/$userId?left=${left(0)}&left=${left(1)}", - s"path(users, $userId, NonEmptyChunk(${left(0)}, ${left(1)}))", + s"path(users, $userId, ${Chunk.fromIterable(left)})", ) && testRoutes( s"/users/$userId?right=${right(0)}&right=${right(1)}", - s"path(users, $userId, NonEmptyChunk(${right(0)}, ${right(1)}))", + s"path(users, $userId, ${Chunk.fromIterable(right)})", ) && testRoutes( s"/users/$userId?right=${right(0)}&right=${right(1)}&left=${left(0)}&left=${left(1)}", - s"path(users, $userId, NonEmptyChunk(${left(0)}, ${left(1)}))", + s"path(users, $userId, ${Chunk.fromIterable(left)})", ) } }, @@ -286,7 +282,7 @@ object QueryParameterSpec extends ZIOHttpSpec { testRoutes( s"/users/$userId?left=${left(0)}&left=${left(1)}", - s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + s"path(users, $userId, Left(${Chunk.fromIterable(left)}))", ) && testRoutes( s"/users/$userId?right=$right", @@ -294,9 +290,44 @@ object QueryParameterSpec extends ZIOHttpSpec { ) && testRoutes( s"/users/$userId?right=$right&left=${left(0)}&left=${left(1)}", - s"path(users, $userId, Left(NonEmptyChunk(${left(0)}, ${left(1)})))", + s"path(users, $userId, Left(${Chunk.fromIterable(left)}))", ) } }, + test("query parameters keys without values for multi value query") { + val testRoutes = testEndpoint( + Routes( + Endpoint(GET / "users") + .query(queryMultiValueInt("ints")) + .out[String] + .implement { + Handler.fromFunction { case queryParams => + s"path(users, $queryParams)" + } + }, + ), + ) _ + + testRoutes( + s"/users?ints", + s"path(users, ${Chunk.empty})", + ) + }, + test("no specified query parameters for multi value query") { + val testRoutes = Routes( + Endpoint(GET / "users") + .query(queryMultiValueInt("ints")) + .out[String] + .implement { + Handler.fromFunction { case queryParams => + s"path(users, $queryParams)" + } + }, + ) + + testRoutes.toHttpApp + .runZIO(Request.get("/users")) + .map(resp => assertTrue(resp.status == Status.BadRequest)) + }, ) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index 53d04790e9..d979545d0c 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -591,7 +591,7 @@ 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, NonEmptyChunk[A]] { + extends Atom[HttpCodecType.Query, Chunk[A]] { self => def erase: Query[Any] = self.asInstanceOf[Query[Any]] diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index 8a9b7ec77f..f5fb91a139 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -15,7 +15,7 @@ */ package zio.http.codec -import zio.NonEmptyChunk +import zio.{Chunk, NonEmptyChunk} import zio.stacktracer.TracingImplicits.disableAutoTrace private[codec] trait QueryCodecs { @@ -32,19 +32,19 @@ private[codec] trait QueryCodecs { def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = toSingleValue(HttpCodec.Query(name, codec)) - def queryMultiValue(name: String): QueryCodec[NonEmptyChunk[String]] = + def queryMultiValue(name: String): QueryCodec[Chunk[String]] = HttpCodec.Query(name, TextCodec.string) - def queryMultiValueBool(name: String): QueryCodec[NonEmptyChunk[Boolean]] = + def queryMultiValueBool(name: String): QueryCodec[Chunk[Boolean]] = HttpCodec.Query(name, TextCodec.boolean) - def queryMultiValueInt(name: String): QueryCodec[NonEmptyChunk[Int]] = + def queryMultiValueInt(name: String): QueryCodec[Chunk[Int]] = HttpCodec.Query(name, TextCodec.int) - def queryMultiValueTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[NonEmptyChunk[A]] = + def queryMultiValueTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = HttpCodec.Query(name, codec) - private def toSingleValue[A](queryCodec: QueryCodec[NonEmptyChunk[A]]): QueryCodec[A] = - queryCodec.transform { (c: NonEmptyChunk[A]) => c.head }(s => NonEmptyChunk(s)) + private def toSingleValue[A](queryCodec: QueryCodec[Chunk[A]]): QueryCodec[A] = + queryCodec.transform { (c: Chunk[A]) => c.head }(s => NonEmptyChunk(s)) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala index 59c350bd24..98c4bbe539 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala @@ -315,13 +315,14 @@ private[codec] object EncoderDecoder { while (i < queries.length) { val query = queries(i).erase - val queryParamValues = - queryParams - .getAllOrElse(query.name, Nil) - .collect(query.textCodec) + val params = queryParams.getAllOrElse(query.name, Nil) - if (queryParamValues.size > 0) inputs(i) = NonEmptyChunk.fromChunk(queryParamValues).get - else throw HttpCodecError.MissingQueryParam(query.name) + if (params.isEmpty) + throw HttpCodecError.MissingQueryParam(query.name) + else { + val parsedParams = params.collect(query.textCodec) + inputs(i) = parsedParams + } i = i + 1 } @@ -507,12 +508,16 @@ private[codec] object EncoderDecoder { val query = flattened.query(i).erase val input = inputs(i) - val inputCoerced = input.asInstanceOf[NonEmptyChunk[Any]] + val inputCoerced = input.asInstanceOf[Chunk[Any]] + pprint.pprintln(inputCoerced) - inputCoerced.foreach { in => - val value = query.textCodec.encode(in) - queryParams = queryParams.add(query.name, value) - } + if (inputCoerced.isEmpty) + queryParams.addAll(query.name, Chunk.empty[String]) + else + inputCoerced.foreach { in => + val value = query.textCodec.encode(in) + queryParams = queryParams.add(query.name, value) + } i = i + 1 } From b3e065baf2f28650bfc9ba3b600f7b882e290f9e Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Sat, 20 Jan 2024 17:15:49 +0100 Subject: [PATCH 6/9] Fail if single parameter query actually received more values --- .../http/endpoint/QueryParameterSpec.scala | 26 ++++++++++++++----- .../main/scala/zio/http/codec/HttpCodec.scala | 2 +- .../scala/zio/http/codec/QueryCodecs.scala | 18 +++++++------ .../http/codec/internal/EncoderDecoder.scala | 1 - 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index 85b0596b0a..c7f8932012 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala @@ -17,19 +17,14 @@ package zio.http.endpoint import java.time.Instant + import zio._ import zio.test._ -import zio.stream.ZStream -import zio.schema.annotation.validate -import zio.schema.validation.Validation -import zio.schema.{DeriveSchema, Schema} -import zio.http.Header.ContentType + import zio.http.Method._ import zio.http._ import zio.http.codec.HttpCodec.{query, queryInt, queryMultiValue, queryMultiValueBool, queryMultiValueInt} -import zio.http.codec._ import zio.http.endpoint.EndpointSpec.testEndpoint -import zio.http.forms.Fixtures.formField object QueryParameterSpec extends ZIOHttpSpec { def spec = suite("QueryParameterSpec")( @@ -329,5 +324,22 @@ object QueryParameterSpec extends ZIOHttpSpec { .runZIO(Request.get("/users")) .map(resp => assertTrue(resp.status == Status.BadRequest)) }, + test("multiple query parameter values to single value query parameter codec") { + val testRoutes = + Routes( + Endpoint(GET / "users") + .query(queryInt("ints")) + .out[String] + .implement { + Handler.fromFunction { case queryParams => + s"path(users, $queryParams)" + } + }, + ) + + testRoutes.toHttpApp + .runZIO(Request.get(URL.decode("/users?ints=1&ints=2").toOption.get)) + .map(resp => assertTrue(resp.status == Status.BadRequest)) + }, ) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index d979545d0c..f858bc4238 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -553,7 +553,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with } private[http] final case class Status[A](codec: SimpleCodec[zio.http.Status, A], index: Int = 0) - extends Atom[HttpCodecType.Status, A] { + extends Atom[HttpCodecType.Status, A] { self => def erase: Status[Any] = self.asInstanceOf[Status[Any]] diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index f5fb91a139..f63f4d5c6e 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -15,22 +15,22 @@ */ package zio.http.codec -import zio.{Chunk, NonEmptyChunk} import zio.stacktracer.TracingImplicits.disableAutoTrace +import zio.{Chunk, NonEmptyChunk} private[codec] trait QueryCodecs { def query(name: String): QueryCodec[String] = - toSingleValue(HttpCodec.Query(name, TextCodec.string)) + toSingleValue(name, HttpCodec.Query(name, TextCodec.string)) def queryBool(name: String): QueryCodec[Boolean] = - toSingleValue(HttpCodec.Query(name, TextCodec.boolean)) + toSingleValue(name, HttpCodec.Query(name, TextCodec.boolean)) def queryInt(name: String): QueryCodec[Int] = - toSingleValue(HttpCodec.Query(name, TextCodec.int)) + toSingleValue(name, HttpCodec.Query(name, TextCodec.int)) def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = - toSingleValue(HttpCodec.Query(name, codec)) + toSingleValue(name, HttpCodec.Query(name, codec)) def queryMultiValue(name: String): QueryCodec[Chunk[String]] = HttpCodec.Query(name, TextCodec.string) @@ -44,7 +44,9 @@ private[codec] trait QueryCodecs { def queryMultiValueTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = HttpCodec.Query(name, codec) - private def toSingleValue[A](queryCodec: QueryCodec[Chunk[A]]): QueryCodec[A] = - queryCodec.transform { (c: Chunk[A]) => c.head }(s => NonEmptyChunk(s)) - + private def toSingleValue[A](name: String, queryCodec: QueryCodec[Chunk[A]]): QueryCodec[A] = + queryCodec.transformOrFail { + case chunk: Chunk[A] if chunk.size == 1 => Right(chunk.head) + case chunk => Left(s"Expected single value for query parameter $name, but got ${chunk.size} instead") + }(s => Right(Chunk(s))) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala index 98c4bbe539..534c9c4f1c 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala @@ -509,7 +509,6 @@ private[codec] object EncoderDecoder { val input = inputs(i) val inputCoerced = input.asInstanceOf[Chunk[Any]] - pprint.pprintln(inputCoerced) if (inputCoerced.isEmpty) queryParams.addAll(query.name, Chunk.empty[String]) From 94359a373b745610aa37aed6a3095ecc62c53ef4 Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Sat, 20 Jan 2024 23:02:04 +0100 Subject: [PATCH 7/9] Rename to queryAllXXX --- .../http/endpoint/QueryParameterSpec.scala | 20 +++++++++---------- .../scala/zio/http/codec/QueryCodecs.scala | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index c7f8932012..53cb7557de 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala @@ -23,7 +23,7 @@ import zio.test._ import zio.http.Method._ import zio.http._ -import zio.http.codec.HttpCodec.{query, queryInt, queryMultiValue, queryMultiValueBool, queryMultiValueInt} +import zio.http.codec.HttpCodec.{query, queryInt, queryAll, queryAllBool, queryAllInt} import zio.http.endpoint.EndpointSpec.testEndpoint object QueryParameterSpec extends ZIOHttpSpec { @@ -101,7 +101,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("key")) + .query(queryAll("key")) .out[String] .implement { Handler.fromFunction { case (userId, keys) => @@ -130,7 +130,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("key").optional) + .query(queryAll("key").optional) .out[String] .implement { Handler.fromFunction { case (userId, keys) => @@ -160,7 +160,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("key") & queryMultiValue("value")) + .query(queryAll("key") & queryAll("value")) .out[String] .implement { Handler.fromFunction { case (userId, keys, values) => @@ -185,7 +185,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("multi") & query("single")) + .query(queryAll("multi") & query("single")) .out[String] .implement { Handler.fromFunction { case (userId, multi, single) => @@ -206,7 +206,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("left") | queryMultiValueBool("right")) + .query(queryAll("left") | queryAllBool("right")) .out[String] .implement { Handler.fromFunction { case (userId, eitherOfParameters) => @@ -236,7 +236,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("left") | queryMultiValue("right")) + .query(queryAll("left") | queryAll("right")) .out[String] .implement { Handler.fromFunction { case (userId, queryParams) => @@ -265,7 +265,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users" / int("userId")) - .query(queryMultiValue("left") | query("right")) + .query(queryAll("left") | query("right")) .out[String] .implement { Handler.fromFunction { case (userId, queryParams) => @@ -293,7 +293,7 @@ object QueryParameterSpec extends ZIOHttpSpec { val testRoutes = testEndpoint( Routes( Endpoint(GET / "users") - .query(queryMultiValueInt("ints")) + .query(queryAllInt("ints")) .out[String] .implement { Handler.fromFunction { case queryParams => @@ -311,7 +311,7 @@ object QueryParameterSpec extends ZIOHttpSpec { test("no specified query parameters for multi value query") { val testRoutes = Routes( Endpoint(GET / "users") - .query(queryMultiValueInt("ints")) + .query(queryAllInt("ints")) .out[String] .implement { Handler.fromFunction { case queryParams => diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index f63f4d5c6e..98c6403346 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -32,21 +32,21 @@ private[codec] trait QueryCodecs { def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = toSingleValue(name, HttpCodec.Query(name, codec)) - def queryMultiValue(name: String): QueryCodec[Chunk[String]] = + def queryAll(name: String): QueryCodec[Chunk[String]] = HttpCodec.Query(name, TextCodec.string) - def queryMultiValueBool(name: String): QueryCodec[Chunk[Boolean]] = + def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = HttpCodec.Query(name, TextCodec.boolean) - def queryMultiValueInt(name: String): QueryCodec[Chunk[Int]] = + def queryAllInt(name: String): QueryCodec[Chunk[Int]] = HttpCodec.Query(name, TextCodec.int) - def queryMultiValueTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = + def queryAllTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = HttpCodec.Query(name, codec) private def toSingleValue[A](name: String, queryCodec: QueryCodec[Chunk[A]]): QueryCodec[A] = queryCodec.transformOrFail { - case chunk: Chunk[A] if chunk.size == 1 => Right(chunk.head) + case chunk if chunk.size == 1 => Right(chunk.head) case chunk => Left(s"Expected single value for query parameter $name, but got ${chunk.size} instead") }(s => Right(Chunk(s))) } From f875728f411c4365ced3ec221da1c27321c915e8 Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Sat, 20 Jan 2024 23:24:48 +0100 Subject: [PATCH 8/9] scalafix --- .../src/test/scala/zio/http/endpoint/QueryParameterSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index 53cb7557de..3e7990e465 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala @@ -23,7 +23,7 @@ import zio.test._ import zio.http.Method._ import zio.http._ -import zio.http.codec.HttpCodec.{query, queryInt, queryAll, queryAllBool, queryAllInt} +import zio.http.codec.HttpCodec.{query, queryAll, queryAllBool, queryAllInt, queryInt} import zio.http.endpoint.EndpointSpec.testEndpoint object QueryParameterSpec extends ZIOHttpSpec { From 8c376e2b60ed68db2942d15c6cbd8208288189c5 Mon Sep 17 00:00:00 2001 From: Egor Gorokhov Date: Mon, 22 Jan 2024 19:23:21 +0100 Subject: [PATCH 9/9] Add QueryParamHint to hint on how many params codec expects --- .../zio/http/endpoint/cli/CliEndpoint.scala | 2 +- .../zio/http/endpoint/cli/EndpointGen.scala | 3 +- .../main/scala/zio/http/codec/HttpCodec.scala | 25 +++++++++-- .../scala/zio/http/codec/QueryCodecs.scala | 43 +++++++++---------- .../http/endpoint/openapi/OpenAPIGen.scala | 2 +- 5 files changed, 47 insertions(+), 28 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 dffbc04aa1..edb381c492 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 @@ -123,7 +123,7 @@ private[cli] object CliEndpoint { case HttpCodec.Path(pathCodec, _) => CliEndpoint(url = HttpOptions.Path(pathCodec) :: List()) - case HttpCodec.Query(name, textCodec, _) => + 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()) 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..67db3627a6 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 @@ -7,6 +7,7 @@ import zio.test._ import zio.schema._ import zio.http._ +import zio.http.codec.HttpCodec.Query.QueryParamHint import zio.http.codec._ import zio.http.endpoint._ import zio.http.endpoint.cli.AuxGen._ @@ -93,7 +94,7 @@ object EndpointGen { lazy val anyQuery: Gen[Any, CliReprOf[Codec[_]]] = Gen.alphaNumericStringBounded(1, 30).zip(anyTextCodec).map { case (name, codec) => CliRepr( - HttpCodec.Query(name, codec), + HttpCodec.Query(name, codec, QueryParamHint.Any), codec 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/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index f858bc4238..d9a6a6c7b6 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -553,7 +553,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with } private[http] final case class Status[A](codec: SimpleCodec[zio.http.Status, A], index: Int = 0) - extends Atom[HttpCodecType.Status, A] { + extends Atom[HttpCodecType.Status, A] { self => def erase: Status[Any] = self.asInstanceOf[Status[Any]] @@ -590,8 +590,12 @@ 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, Chunk[A]] { + private[http] final case class Query[A]( + name: String, + textCodec: TextCodec[A], + hint: Query.QueryParamHint, + index: Int = 0, + ) extends Atom[HttpCodecType.Query, Chunk[A]] { self => def erase: Query[Any] = self.asInstanceOf[Query[Any]] @@ -600,6 +604,21 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with def index(index: Int): Query[A] = copy(index = index) } + private[http] object Query { + + // Hint on how many query parameters codec expects + sealed trait QueryParamHint + object QueryParamHint { + case object One extends QueryParamHint + + case object Many extends QueryParamHint + + case object Zero extends QueryParamHint + + case object Any extends QueryParamHint + } + } + private[http] final case class Method[A](codec: SimpleCodec[zio.http.Method, A], index: Int = 0) extends Atom[HttpCodecType.Method, A] { self => diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index 98c6403346..7a07f78cba 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -15,38 +15,37 @@ */ package zio.http.codec +import zio.Chunk import zio.stacktracer.TracingImplicits.disableAutoTrace -import zio.{Chunk, NonEmptyChunk} + +import zio.http.codec.HttpCodec.Query.QueryParamHint private[codec] trait QueryCodecs { - def query(name: String): QueryCodec[String] = - toSingleValue(name, HttpCodec.Query(name, TextCodec.string)) + def query(name: String): QueryCodec[String] = singleValueCodec(name, TextCodec.string) + + def queryBool(name: String): QueryCodec[Boolean] = singleValueCodec(name, TextCodec.boolean) - def queryBool(name: String): QueryCodec[Boolean] = - toSingleValue(name, HttpCodec.Query(name, TextCodec.boolean)) + def queryInt(name: String): QueryCodec[Int] = singleValueCodec(name, TextCodec.int) - def queryInt(name: String): QueryCodec[Int] = - toSingleValue(name, HttpCodec.Query(name, TextCodec.int)) + def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = singleValueCodec(name, codec) - def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = - toSingleValue(name, HttpCodec.Query(name, codec)) + def queryAll(name: String): QueryCodec[Chunk[String]] = multiValueCodec(name, TextCodec.string) - def queryAll(name: String): QueryCodec[Chunk[String]] = - HttpCodec.Query(name, TextCodec.string) + def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = multiValueCodec(name, TextCodec.boolean) - def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = - HttpCodec.Query(name, TextCodec.boolean) + def queryAllInt(name: String): QueryCodec[Chunk[Int]] = multiValueCodec(name, TextCodec.int) - def queryAllInt(name: String): QueryCodec[Chunk[Int]] = - HttpCodec.Query(name, TextCodec.int) + def queryAllTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = multiValueCodec(name, codec) - def queryAllTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = - HttpCodec.Query(name, codec) + private def singleValueCodec[A](name: String, textCodec: TextCodec[A]): QueryCodec[A] = + HttpCodec + .Query(name, textCodec, QueryParamHint.One) + .transformOrFail { + case chunk if chunk.size == 1 => Right(chunk.head) + case chunk => Left(s"Expected single value for query parameter $name, but got ${chunk.size} instead") + }(s => Right(Chunk(s))) - private def toSingleValue[A](name: String, queryCodec: QueryCodec[Chunk[A]]): QueryCodec[A] = - queryCodec.transformOrFail { - case chunk if chunk.size == 1 => Right(chunk.head) - case chunk => Left(s"Expected single value for query parameter $name, but got ${chunk.size} instead") - }(s => Right(Chunk(s))) + private def multiValueCodec[A](name: String, textCodec: TextCodec[A]): QueryCodec[Chunk[A]] = + HttpCodec.Query(name, textCodec, QueryParamHint.Many) } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala index 9a28077a54..66e586910c 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -601,7 +601,7 @@ object OpenAPIGen { queryParams ++ pathParams ++ headerParams def queryParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = { - inAtoms.query.collect { case mc @ MetaCodec(HttpCodec.Query(name, codec, _), _) => + inAtoms.query.collect { case mc @ MetaCodec(HttpCodec.Query(name, codec, _, _), _) => OpenAPI.ReferenceOr.Or( OpenAPI.Parameter.queryParameter( name = name,