Skip to content

Commit

Permalink
Support query parameters with multiple values
Browse files Browse the repository at this point in the history
  • Loading branch information
eshu committed Oct 12, 2023
1 parent f5c67c3 commit 75afb82
Show file tree
Hide file tree
Showing 18 changed files with 437 additions and 98 deletions.
Original file line number Diff line number Diff line change
@@ -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._

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions zio-http-cli/src/test/scala/zio/http/endpoint/cli/AuxGen.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package zio.http.endpoint.cli

import zio.ZNothing
import zio.cli._
import zio.test._

Expand All @@ -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._

Expand Down Expand Up @@ -46,20 +44,20 @@ 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
case (name, "boolean") => s"[${getName(name, "")}]" :: Nil
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
},
Expand Down
2 changes: 1 addition & 1 deletion zio-http-example/src/main/scala/example/CliExamples.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions zio-http/src/main/scala/zio/http/codec/HttpCodec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 17 additions & 8 deletions zio-http/src/main/scala/zio/http/codec/HttpCodecError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
31 changes: 12 additions & 19 deletions zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
79 changes: 79 additions & 0 deletions zio-http/src/main/scala/zio/http/codec/TextChunkCodec.scala
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ 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, _]],
) { self =>
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))
Expand Down
Loading

0 comments on commit 75afb82

Please sign in to comment.