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 0067004bb9..8d85692b63 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 @@ -127,11 +127,8 @@ 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 HttpCodec.Query(name, codec, _, _) => + CliEndpoint(url = HttpOptions.Query(name, 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 3be96b5156..be0c02aba9 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 @@ -3,6 +3,7 @@ package zio.http.endpoint.cli import scala.language.implicitConversions import scala.util.Try +import zio.Chunk import zio.cli._ import zio.json.ast._ @@ -261,11 +262,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: BinaryCodecWithSchema[_], doc: Doc = Doc.empty) + extends URLOptions { self => override val tag = "?" + name - lazy val options: Options[_] = optionsFromTextCodec(textCodec)(name) + lazy val options: Options[_] = optionsFromSchema(codec)(name) override def ??(doc: Doc): Query = self.copy(doc = self.doc + doc) @@ -289,6 +291,76 @@ private[cli] object HttpOptions { } + private[cli] def optionsFromSchema[A](codec: BinaryCodecWithSchema[A]): String => Options[A] = + codec.schema match { + case Schema.Primitive(standardType, _) => + standardType match { + case StandardType.UnitType => + _ => Options.Empty + case StandardType.StringType => + Options.text + case StandardType.BoolType => + Options.boolean(_) + case StandardType.ByteType => + Options.integer(_).map(_.toByte) + case StandardType.ShortType => + Options.integer(_).map(_.toShort) + case StandardType.IntType => + Options.integer(_).map(_.toInt) + case StandardType.LongType => + Options.integer(_).map(_.toLong) + case StandardType.FloatType => + Options.decimal(_).map(_.toFloat) + case StandardType.DoubleType => + Options.decimal(_).map(_.toDouble) + case StandardType.BinaryType => + Options.text(_).map(_.getBytes).map(Chunk.fromArray) + case StandardType.CharType => + Options.text(_).map(_.charAt(0)) + case StandardType.UUIDType => + Options.text(_).map(java.util.UUID.fromString) + case StandardType.CurrencyType => + Options.text(_).map(java.util.Currency.getInstance) + case StandardType.BigDecimalType => + Options.decimal(_).map(_.bigDecimal) + case StandardType.BigIntegerType => + Options.integer(_).map(_.bigInteger) + case StandardType.DayOfWeekType => + Options.integer(_).map(i => java.time.DayOfWeek.of(i.toInt)) + case StandardType.MonthType => + Options.text(_).map(java.time.Month.valueOf) + case StandardType.MonthDayType => + Options.text(_).map(java.time.MonthDay.parse) + case StandardType.PeriodType => + Options.text(_).map(java.time.Period.parse) + case StandardType.YearType => + Options.integer(_).map(i => java.time.Year.of(i.toInt)) + case StandardType.YearMonthType => + Options.text(_).map(java.time.YearMonth.parse) + case StandardType.ZoneIdType => + Options.text(_).map(java.time.ZoneId.of) + case StandardType.ZoneOffsetType => + Options.text(_).map(java.time.ZoneOffset.of) + case StandardType.DurationType => + Options.text(_).map(java.time.Duration.parse) + case StandardType.InstantType => + Options.instant(_) + case StandardType.LocalDateType => + Options.localDate(_) + case StandardType.LocalTimeType => + Options.localTime(_) + case StandardType.LocalDateTimeType => + Options.localDateTime(_) + case StandardType.OffsetTimeType => + Options.text(_).map(java.time.OffsetTime.parse) + case StandardType.OffsetDateTimeType => + Options.text(_).map(java.time.OffsetDateTime.parse) + case StandardType.ZonedDateTimeType => + Options.text(_).map(java.time.ZonedDateTime.parse) + } + case schema => throw new NotImplementedError(s"Schema $schema not yet supported") + } + private[cli] def optionsFromTextCodec[A](textCodec: TextCodec[A]): (String => Options[A]) = textCodec match { case TextCodec.UUIDCodec => 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 ac69bffc7c..7625f97843 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 @@ -47,7 +47,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 @@ -55,12 +55,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) 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 { @@ -121,6 +121,45 @@ object CommandGen { case _ => "" } + def getType[A](codec: BinaryCodecWithSchema[A]): String = + codec.schema match { + case Schema.Primitive(standardType, _) => + standardType match { + case StandardType.UnitType => "" + case StandardType.StringType => "text" + case StandardType.BoolType => "bool" + case StandardType.ByteType => "integer" + case StandardType.ShortType => "integer" + case StandardType.IntType => "integer" + case StandardType.LongType => "integer" + case StandardType.FloatType => "decimal" + case StandardType.DoubleType => "decimal" + case StandardType.BinaryType => "binary" + case StandardType.CharType => "text" + case StandardType.UUIDType => "text" + case StandardType.CurrencyType => "currency" + case StandardType.BigDecimalType => "decimal" + case StandardType.BigIntegerType => "integer" + case StandardType.DayOfWeekType => "integer" + case StandardType.MonthType => "text" + case StandardType.MonthDayType => "text" + case StandardType.PeriodType => "text" + case StandardType.YearType => "integer" + case StandardType.YearMonthType => "text" + case StandardType.ZoneIdType => "text" + case StandardType.ZoneOffsetType => "text" + case StandardType.DurationType => "text" + case StandardType.InstantType => "instant" + case StandardType.LocalDateType => "date" + case StandardType.LocalTimeType => "time" + case StandardType.LocalDateTimeType => "datetime" + case StandardType.OffsetTimeType => "time" + case StandardType.OffsetDateTimeType => "datetime" + case StandardType.ZonedDateTimeType => "datetime" + } + case _ => "" + } + def getPrimitive(schema: Schema[_]): String = schema match { case Schema.Primitive(standardType, _) => 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 9c5bb97b2c..153bb24507 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 @@ -3,9 +3,12 @@ package zio.http.endpoint.cli import zio.ZNothing import zio.test._ +import zio.schema.{Schema, StandardType} + import zio.http._ import zio.http.codec.HttpCodec.Query.QueryParamHint import zio.http.codec._ +import zio.http.codec.internal.TextBinaryCodec import zio.http.endpoint._ import zio.http.endpoint.cli.AuxGen._ import zio.http.endpoint.cli.CliRepr.CliReprOf @@ -99,13 +102,12 @@ object EndpointGen { } lazy val anyQuery: Gen[Any, CliReprOf[Codec[_]]] = - Gen.alphaNumericStringBounded(1, 30).zip(anyTextCodec).map { case (name, codec) => + Gen.alphaNumericStringBounded(1, 30).zip(anyStandardType).map { case (name, schema0) => + val schema = schema0.asInstanceOf[Schema[Any]] + val codec = BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema) CliRepr( 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) - }, + 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 07e0b08629..2604ba4f44 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 @@ -4,8 +4,11 @@ import zio._ import zio.cli._ import zio.test.Gen +import zio.schema.Schema + import zio.http._ import zio.http.codec._ +import zio.http.codec.internal.TextBinaryCodec import zio.http.endpoint.cli.AuxGen._ import zio.http.endpoint.cli.CliRepr._ @@ -30,6 +33,11 @@ object OptionsGen { .optionsFromTextCodec(textCodec)(name) .map(value => textCodec.encode(value)) + def encodeOptions[A](name: String, codec: BinaryCodecWithSchema[A]): Options[String] = + HttpOptions + .optionsFromSchema(codec)(name) + .map(value => codec.codec.encode(value).asString) + lazy val anyBodyOption: Gen[Any, CliReprOf[Options[Retriever]]] = Gen .alphaNumericStringBounded(1, 30) @@ -76,25 +84,22 @@ object OptionsGen { }, Gen .alphaNumericStringBounded(1, 30) - .zip(anyTextCodec) - .map { - case (name, TextCodec.Constant(value)) => - CliRepr( - Options.Empty.map(_ => value), - CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: Nil), - ) - case (name, codec) => - CliRepr( - encodeOptions(name, codec), - CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil), - ) + .zip(anyStandardType.map { s => + val schema = s.asInstanceOf[Schema[Any]] + BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema) + }) + .map { case (name, codec) => + CliRepr( + encodeOptions(name, codec), + CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil), + ) }, ) lazy val anyMethod: Gen[Any, CliReprOf[Method]] = - Gen.fromIterable(List(Method.GET, Method.DELETE, Method.POST, Method.PUT)).map { case method => - CliRepr(method, CliEndpoint(methods = method)) - } + Gen + .fromIterable(List(Method.GET, Method.DELETE, Method.POST, Method.PUT)) + .map(method => CliRepr(method, CliEndpoint(methods = method))) lazy val anyCliEndpoint: Gen[Any, CliReprOf[Options[CliRequest]]] = Gen 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 768de94dff..a6cdb2412e 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 @@ -16,8 +16,6 @@ package zio.http.endpoint -import java.time.Instant - import zio._ import zio.test._ @@ -296,9 +294,7 @@ object QueryParameterSpec extends ZIOHttpSpec { .query(queryAllInt("ints")) .out[String] .implementHandler { - Handler.fromFunction { case queryParams => - s"path(users, $queryParams)" - } + Handler.fromFunction { queryParams => s"path(users, $queryParams)" } }, ), ) _ diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala index aef2838f4c..e0ce3c8501 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala @@ -25,6 +25,8 @@ 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.Authorization @@ -32,7 +34,7 @@ import zio.http.Method._ import zio.http._ import zio.http.codec.HttpCodec.authorization import zio.http.codec.HttpContentCodec.protobuf -import zio.http.codec.{Doc, HeaderCodec, HttpCodec, HttpContentCodec, QueryCodec} +import zio.http.codec._ import zio.http.endpoint.EndpointSpec.ImageMetadata import zio.http.netty.NettyConfig import zio.http.netty.server.NettyDriver @@ -63,6 +65,17 @@ object RoundtripSpec extends ZIOHttpSpec { implicit val schema: Schema[Post] = DeriveSchema.gen[Post] } + case class Age(@validate(Validation.greaterThan(18)) ignoredFieldName: Int) + object Age { + implicit val schema: Schema[Age] = DeriveSchema.gen[Age] + } + + final case class PostWithAge(id: Int, title: String, body: String, userId: Int, age: Age) + + object PostWithAge { + implicit val schema: Schema[PostWithAge] = DeriveSchema.gen[PostWithAge] + } + def makeExecutor(client: Client, port: Int): EndpointExecutor[Unit] = { val locator = EndpointLocator.fromURL( URL.decode(s"http://localhost:$port").toOption.get, @@ -209,31 +222,66 @@ object RoundtripSpec extends ZIOHttpSpec { .query(HttpCodec.queryInt("id")) .query(HttpCodec.query("name").optional) .query(HttpCodec.query("details").optional) - .out[Post] + .query(HttpCodec.queryTo[Age]("age").optional) + .out[PostWithAge] val handler = api.implementHandler { - Handler.fromFunction { case (id, userId, name, details) => - Post(id, name.getOrElse("-"), details.getOrElse("-"), userId) + Handler.fromFunction { case (id, userId, name, details, age) => + PostWithAge(id, name.getOrElse("-"), details.getOrElse("-"), userId, age.getOrElse(Age(20))) } } testEndpoint( api, Routes(handler), - (10, 20, None, Some("x")), - Post(10, "-", "x", 20), + (10, 20, None, Some("x"), None), + PostWithAge(10, "-", "x", 20, Age(20)), ) && testEndpoint( api, Routes(handler), - (10, 20, None, None), - Post(10, "-", "-", 20), + (10, 20, None, None, None), + PostWithAge(10, "-", "-", 20, Age(20)), ) && testEndpoint( api, Routes(handler), - (10, 20, Some("x"), Some("y")), - Post(10, "x", "y", 20), + (10, 20, Some("x"), Some("y"), Some(Age(23))), + PostWithAge(10, "x", "y", 20, Age(23)), + ) + }, + test("simple get with query params that fails validation") { + val api = + Endpoint(GET / "users" / int("userId")) + .query(HttpCodec.queryInt("id")) + .query(HttpCodec.query("name").optional) + .query(HttpCodec.query("details").optional) + .query(HttpCodec.queryTo[Age]("age").optional) + .out[PostWithAge] + + val handler = + api.implementHandler { + Handler.fromFunction { case (id, userId, name, details, age) => + PostWithAge(id, name.getOrElse("-"), details.getOrElse("-"), userId, age.getOrElse(Age(0))) + } + } + + testEndpoint( + api, + Routes(handler), + (10, 20, Some("x"), Some("y"), Some(Age(17))), + PostWithAge(10, "x", "y", 20, Age(17)), + ).catchAllCause(t => + ZIO.succeed( + assertTrue( + t.dieOption.contains( + HttpCodecError.CustomError( + name = "InvalidEntity", + message = "A well-formed entity failed validation: 17 should be greater than 18", + ), + ), + ), + ), ) }, test("throwing error in handler") { 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 cd5175e70c..1789f654e6 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 @@ -25,6 +25,8 @@ import zio._ import zio.stream.ZStream import zio.schema.Schema +import zio.schema.annotation.validate +import zio.schema.validation.Validation import zio.http.Header.Accept.MediaTypeWithQFactor import zio.http._ @@ -584,7 +586,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with } private[http] final case class Query[A]( name: String, - textCodec: TextCodec[A], + codec: BinaryCodecWithSchema[A], hint: Query.QueryParamHint, index: Int = 0, ) extends Atom[HttpCodecType.Query, Chunk[A]] { diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala index cd0645fde2..bd266d3e38 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala @@ -21,6 +21,7 @@ import scala.util.control.NoStackTrace import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.{Cause, Chunk} +import zio.schema.codec.DecodeError import zio.schema.validation.ValidationError import zio.http.{Path, Status} @@ -51,8 +52,8 @@ object 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 { - def message = s"Malformed query parameter $queryParamName failed to decode using $textCodec" + final case class MalformedQueryParam(queryParamName: String, cause: DecodeError) extends HttpCodecError { + def message = s"Malformed query parameter $queryParamName could not be decoded: $cause" } final case class MalformedBody(details: String, cause: Option[Throwable] = None) extends HttpCodecError { def message = s"Malformed request body failed to decode: $details" @@ -63,7 +64,7 @@ object HttpCodecError { object InvalidEntity { def wrap(errors: Chunk[ValidationError]): InvalidEntity = InvalidEntity( - errors.foldLeft("")((acc, err) => acc + err.message + "\n"), + errors.map(err => err.message).mkString("\n"), errors, ) } 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 7a07f78cba..3320adee3b 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 @@ -18,34 +18,37 @@ package zio.http.codec import zio.Chunk import zio.stacktracer.TracingImplicits.disableAutoTrace +import zio.schema.Schema + import zio.http.codec.HttpCodec.Query.QueryParamHint +import zio.http.codec.internal.TextBinaryCodec private[codec] trait QueryCodecs { - def query(name: String): QueryCodec[String] = singleValueCodec(name, TextCodec.string) + def query(name: String): QueryCodec[String] = singleValueCodec(name, Schema[String]) - def queryBool(name: String): QueryCodec[Boolean] = singleValueCodec(name, TextCodec.boolean) + def queryBool(name: String): QueryCodec[Boolean] = singleValueCodec(name, Schema[Boolean]) - def queryInt(name: String): QueryCodec[Int] = singleValueCodec(name, TextCodec.int) + def queryInt(name: String): QueryCodec[Int] = singleValueCodec(name, Schema[Int]) - def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = singleValueCodec(name, codec) + def queryTo[A](name: String)(implicit codec: Schema[A]): QueryCodec[A] = singleValueCodec(name, codec) - def queryAll(name: String): QueryCodec[Chunk[String]] = multiValueCodec(name, TextCodec.string) + def queryAll(name: String): QueryCodec[Chunk[String]] = multiValueCodec(name, Schema[String]) - def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = multiValueCodec(name, TextCodec.boolean) + def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = multiValueCodec(name, Schema[Boolean]) - def queryAllInt(name: String): QueryCodec[Chunk[Int]] = multiValueCodec(name, TextCodec.int) + def queryAllInt(name: String): QueryCodec[Chunk[Int]] = multiValueCodec(name, Schema[Int]) - def queryAllTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = multiValueCodec(name, codec) + def queryAllTo[A](name: String)(implicit codec: Schema[A]): QueryCodec[Chunk[A]] = multiValueCodec(name, codec) - private def singleValueCodec[A](name: String, textCodec: TextCodec[A]): QueryCodec[A] = + private def singleValueCodec[A](name: String, schema: Schema[A]): QueryCodec[A] = HttpCodec - .Query(name, textCodec, QueryParamHint.One) + .Query(name, BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema), 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 multiValueCodec[A](name: String, textCodec: TextCodec[A]): QueryCodec[Chunk[A]] = - HttpCodec.Query(name, textCodec, QueryParamHint.Many) + private def multiValueCodec[A](name: String, schema: Schema[A]): QueryCodec[Chunk[A]] = + HttpCodec.Query(name, BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema), QueryParamHint.Many) } 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 7663d2461c..fce7fc96b2 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 @@ -27,6 +27,7 @@ import zio.schema.codec.BinaryCodec import zio.http.Header.Accept.MediaTypeWithQFactor import zio.http._ +import zio.http.codec.HttpCodec.Query import zio.http.codec._ private[codec] trait EncoderDecoder[-AtomTypes, Value] { self => @@ -40,6 +41,8 @@ private[codec] trait EncoderDecoder[-AtomTypes, Value] { self => } private[codec] object EncoderDecoder { + private val emptyStringChunk = Chunk("") + def apply[AtomTypes, Value]( httpCodec: HttpCodec[AtomTypes, Value], ): EncoderDecoder[AtomTypes, Value] = { @@ -284,8 +287,21 @@ private[codec] object EncoderDecoder { if (params.isEmpty) throw HttpCodecError.MissingQueryParam(query.name) - else { - val parsedParams = params.collect(query.textCodec) + else if ( + params == emptyStringChunk + && (query.hint == Query.QueryParamHint.Any || query.hint == Query.QueryParamHint.Many) + ) { + inputs(i) = Chunk.empty + } else { + val parsedParams = params.map { p => + val decoded = query.codec.codec.decode(Chunk.fromArray(p.getBytes(Charsets.Utf8))) + decoded match { + case Left(error) => throw HttpCodecError.MalformedQueryParam(query.name, error) + case Right(value) => value + } + } + val validationErrors = parsedParams.flatMap(p => query.codec.schema.validate(p)(query.codec.schema)) + if (validationErrors.nonEmpty) throw HttpCodecError.InvalidEntity.wrap(validationErrors) inputs(i) = parsedParams } @@ -482,7 +498,7 @@ private[codec] object EncoderDecoder { queryParams.addQueryParams(query.name, Chunk.empty[String]) else inputCoerced.foreach { in => - val value = query.textCodec.encode(in) + val value = query.codec.codec.encode(in).asString queryParams = queryParams.addQueryParam(query.name, value) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala index 70158a80d8..65cf54e6fb 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala @@ -32,10 +32,31 @@ object TextBinaryCodec { def fromSchema[A](schema: Schema[A]): BinaryCodec[A] = { schema match { - case enum0: Schema.Enum[_] => errorCodec(enum0) - case record: Schema.Record[_] => errorCodec(record) - case collection: Schema.Collection[_, _] => errorCodec(collection) - case Schema.Transform(schema, f, g, _, _) => + case enum0: Schema.Enum[_] => errorCodec(enum0) + case record: Schema.Record[_] if record.fields.size == 1 => + val fieldSchema = record.fields.head.schema + val codec = fromSchema(fieldSchema).asInstanceOf[BinaryCodec[A]] + new BinaryCodec[A] { + override def encode(a: A): Chunk[Byte] = + codec.encode(record.deconstruct(a)(Unsafe.unsafe).head.get.asInstanceOf[A]) + override def decode(c: Chunk[Byte]): Either[DecodeError, A] = + codec + .decode(c) + .flatMap(a => + record.construct(Chunk(a))(Unsafe.unsafe).left.map(s => DecodeError.ReadError(Cause.empty, s)), + ) + override def streamEncoder: ZPipeline[Any, Nothing, A, Byte] = + ZPipeline.map(a => encode(a)).flattenChunks + override def streamDecoder: ZPipeline[Any, DecodeError, Byte, A] = + codec.streamDecoder.mapZIO(a => + ZIO.fromEither( + record.construct(Chunk(a))(Unsafe.unsafe).left.map(s => DecodeError.ReadError(Cause.empty, s)), + ), + ) + } + case record: Schema.Record[_] => errorCodec(record) + case collection: Schema.Collection[_, _] => errorCodec(collection) + case Schema.Transform(schema, f, g, _, _) => val codec = fromSchema(schema) new BinaryCodec[A] { override def encode(a: A): Chunk[Byte] = codec.encode(g(a).fold(e => throw new Exception(e), identity)) @@ -54,7 +75,7 @@ object TextBinaryCodec { } } } - case Schema.Primitive(_, _) => + case Schema.Primitive(_, _) => new BinaryCodec[A] { val decode0: String => Either[DecodeError, Any] = schema match { @@ -67,10 +88,10 @@ object TextBinaryCodec { (s: String) => Right(s) case StandardType.BoolType => (s: String) => - try { - Right(s.toBoolean) - } catch { - case e: Exception => Left(DecodeError.ReadError(Cause.fail(e), e.getMessage)) + s.toLowerCase match { + case "true" | "on" | "yes" | "1" => Right(true) + case "false" | "off" | "no" | "0" => Right(false) + case _ => Left(DecodeError.ReadError(Cause.fail(new Exception("Invalid boolean value")), s)) } case StandardType.ByteType => (s: String) => @@ -286,8 +307,8 @@ object TextBinaryCodec { .map(s => decode(Chunk.fromArray(s.getBytes)).fold(throw _, identity)) .mapErrorCause(e => Cause.fail(DecodeError.ReadError(e, e.squash.getMessage))) } - case Schema.Lazy(schema0) => fromSchema(schema0()) - case _ => errorCodec(schema) + case Schema.Lazy(schema0) => fromSchema(schema0()) + case _ => errorCodec(schema) } } } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala index 9f30b2b7fb..4cd1764377 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala @@ -49,7 +49,10 @@ private[endpoint] final case class EndpointClient[P, I, E, O, M <: EndpointMiddl } else if (endpoint.error.matchesStatus(response.status)) { endpoint.error.decodeResponse(response).orDie.flip } else { - ZIO.die(new IllegalStateException(s"Status code: ${response.status} is not defined in the endpoint")) + val error = endpoint.codecError.decodeResponse(response) + error + .flatMap(codecError => ZIO.die(codecError)) + .orElse(ZIO.die(new IllegalStateException(s"Status code: ${response.status} is not defined in the endpoint"))) } } } 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 28e57d2783..70f81ccf3b 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 @@ -615,7 +615,8 @@ object OpenAPIGen { OpenAPI.Parameter.queryParameter( name = name, description = mc.docsOpt, - schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec))), + // TODO: For single field case classes we need to use the schema of the field + schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromZSchema(codec.schema))), deprecated = mc.deprecated, style = OpenAPI.Parameter.Style.Form, explode = false,