Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Intoroduce query parameters with multiple values to Endpoint API #2631

Merged
merged 9 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,10 @@ 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.codec.HttpCodec.{query, queryAll, queryAllBool, queryAllInt, queryInt}
import zio.http.endpoint.EndpointSpec.testEndpoint
import zio.http.forms.Fixtures.formField

object QueryParameterSpec extends ZIOHttpSpec {
def spec = suite("QueryParameterSpec")(
Expand Down Expand Up @@ -105,5 +96,250 @@ 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(queryAll("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(queryAll("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(${Chunk.fromIterable(keys)}))",
) &&
testRoutes(
s"/users/$userId",
s"path(users, $userId, None)",
) &&
testRoutes(
s"/users/$userId?key=",
s"path(users, $userId, Some(${Chunk.empty}))",
)
}
},
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(queryAll("key") & queryAll("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, ${Chunk.fromIterable(keys)}, ${Chunk.fromIterable(values)})",
) &&
testRoutes(
s"/users/$userId?key=${keys(0)}&key=${keys(1)}&value=${values(0)}",
s"path(users, $userId, ${Chunk(keys(0), keys(1))}, ${Chunk(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(queryAll("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, ${Chunk.fromIterable(multi)}, $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(queryAll("left") | queryAllBool("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(${Chunk.fromIterable(left)}))",
) &&
testRoutes(
s"/users/$userId?right=${right(0)}&right=${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(${Chunk.fromIterable(left)}))",
)
}
},
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(queryAll("left") | queryAll("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, ${Chunk.fromIterable(left)})",
) &&
testRoutes(
s"/users/$userId?right=${right(0)}&right=${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, ${Chunk.fromIterable(left)})",
)
}
},
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(queryAll("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(${Chunk.fromIterable(left)}))",
) &&
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(${Chunk.fromIterable(left)}))",
)
}
},
test("query parameters keys without values for multi value query") {
val testRoutes = testEndpoint(
Routes(
Endpoint(GET / "users")
.query(queryAllInt("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(queryAllInt("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))
},
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))
},
)
}
23 changes: 21 additions & 2 deletions zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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]]

Expand All @@ -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 =>
Expand Down
Loading
Loading