Skip to content

Commit

Permalink
Schema based ops for en- and decoding bodies (#2569)
Browse files Browse the repository at this point in the history
* Schema based ops for en- and decoding bodies

* Rename `as` methods to `to`; remove duplicated QueryCodec constructors
  • Loading branch information
987Nabil authored Jan 7, 2024
1 parent 80d384d commit 11f13c9
Show file tree
Hide file tree
Showing 12 changed files with 143 additions and 49 deletions.
6 changes: 6 additions & 0 deletions docs/migration/RC4-to-xx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**QueryCodec**
- removed methods that start with `param` use methods starting with `query` instead
- renamed `queryAs` to `queryTo`

**QueryParam**
- renamed all methods that return typed params from `as` to `to`
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 @@ -51,7 +51,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
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ object CodeGen {
case Code.CodecType.UUID => "UUID"
case Code.CodecType.Literal => throw new Exception("Literal query params are not supported")
}
s""".query(QueryCodec.queryAs[$tpe]("$name"))"""
s""".query(QueryCodec.queryTo[$tpe]("$name"))"""
}

def renderInCode(inCode: Code.InCode): String = inCode match {
Expand Down
4 changes: 2 additions & 2 deletions zio-http-gen/src/test/resources/EndpointWithQueryParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ object Users {
import zio.http.endpoint._
import zio.http.codec._
val get = Endpoint(Method.GET / "api" / "v1" / "users")
.query(QueryCodec.queryAs[Int]("limit"))
.query(QueryCodec.queryAs[String]("name"))
.query(QueryCodec.queryTo[Int]("limit"))
.query(QueryCodec.queryTo[String]("name"))
.in[Unit]

}
49 changes: 49 additions & 0 deletions zio-http/src/main/scala/zio/http/Body.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import zio.stacktracer.TracingImplicits.disableAutoTrace

import zio.stream.ZStream

import zio.schema.codec.BinaryCodec

import zio.http.internal.BodyEncoding

/**
Expand All @@ -41,6 +43,23 @@ trait Body { self =>
*/
def ++(that: Body): Body = if (that.isEmpty) self else that

/**
* Decodes the content of the body as a value based on a zio-schema
* [[zio.schema.codec.BinaryCodec]].<br>
*
* Example for json:
* {{{
* import zio.schema.json.codec._
* case class Person(name: String, age: Int)
* implicit val schema: Schema[Person] = DeriveSchema.gen[Person]
* val person = Person("John", 42)
* val body = Body.from(person)
* val decodedPerson = body.to[Person]
* }}}
*/
def to[A](implicit codec: BinaryCodec[A], trace: Trace): Task[A] =
asChunk.flatMap(bytes => ZIO.fromEither(codec.decode(bytes)))

/**
* Returns an effect that decodes the content of the body as array of bytes.
* Note that attempting to decode a large stream of bytes into an array could
Expand Down Expand Up @@ -155,6 +174,20 @@ object Body {
*/
val empty: Body = EmptyBody

/**
* Constructs a [[zio.http.Body]] from a value based on a zio-schema
* [[zio.schema.codec.BinaryCodec]].<br> Example for json:
* {{{
* import zio.schema.codec.JsonCodec._
* case class Person(name: String, age: Int)
* implicit val schema: Schema[Person] = DeriveSchema.gen[Person]
* val person = Person("John", 42)
* val body = Body.from(person)
* }}}
*/
def from[A](a: A)(implicit codec: BinaryCodec[A], trace: Trace): Body =
fromChunk(codec.encode(a))

/**
* Constructs a [[zio.http.Body]] from the contents of a file.
*/
Expand Down Expand Up @@ -215,6 +248,22 @@ object Body {
def fromStream(stream: ZStream[Any, Throwable, Byte], contentLength: Long): Body =
StreamBody(stream, knownContentLength = Some(contentLength))

/**
* Constructs a [[zio.http.Body]] from stream of values based on a zio-schema
* [[zio.schema.codec.BinaryCodec]].<br>
*
* Example for json:
* {{{
* import zio.schema.codec.JsonCodec._
* case class Person(name: String, age: Int)
* implicit val schema: Schema[Person] = DeriveSchema.gen[Person]
* val persons = ZStream(Person("John", 42))
* val body = Body.fromStream(persons)
* }}}
*/
def fromStream[A](stream: ZStream[Any, Throwable, A])(implicit codec: BinaryCodec[A], trace: Trace): Body =
StreamBody(stream >>> codec.streamEncoder, knownContentLength = None)

/**
* Constructs a [[zio.http.Body]] from a stream of bytes of unknown length,
* using chunked transfer encoding.
Expand Down
18 changes: 9 additions & 9 deletions zio-http/src/main/scala/zio/http/QueryParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ final case class QueryParams(map: Map[String, Chunk[String]]) {
/**
* Retrieves all typed query parameter values having the specified name.
*/
def getAllAs[A](key: String)(implicit codec: TextCodec[A]): Either[QueryParamsError, Chunk[A]] = for {
def getAllTo[A](key: String)(implicit codec: TextCodec[A]): Either[QueryParamsError, Chunk[A]] = for {
params <- map.get(key).toRight(QueryParamsError.Missing(key))
(failed, typed) = params.partitionMap(p => codec.decode(p).toRight(p))
result <- NonEmptyChunk
Expand All @@ -104,8 +104,8 @@ final case class QueryParams(map: Map[String, Chunk[String]]) {
* Retrieves all typed query parameter values having the specified name as
* ZIO.
*/
def getAllAsZIO[A](key: String)(implicit codec: TextCodec[A]): IO[QueryParamsError, Chunk[A]] =
ZIO.fromEither(getAllAs[A](key))
def getAllToZIO[A](key: String)(implicit codec: TextCodec[A]): IO[QueryParamsError, Chunk[A]] =
ZIO.fromEither(getAllTo[A](key))

/**
* Retrieves the first query parameter value having the specified name.
Expand All @@ -115,7 +115,7 @@ final case class QueryParams(map: Map[String, Chunk[String]]) {
/**
* Retrieves the first typed query parameter value having the specified name.
*/
def getAs[A](key: String)(implicit codec: TextCodec[A]): Either[QueryParamsError, A] = for {
def getTo[A](key: String)(implicit codec: TextCodec[A]): Either[QueryParamsError, A] = for {
param <- get(key).toRight(QueryParamsError.Missing(key))
typedParam <- codec.decode(param).toRight(QueryParamsError.Malformed(key, codec, NonEmptyChunk(param)))
} yield typedParam
Expand All @@ -124,7 +124,7 @@ final case class QueryParams(map: Map[String, Chunk[String]]) {
* Retrieves the first typed query parameter value having the specified name
* as ZIO.
*/
def getAsZIO[A](key: String)(implicit codec: TextCodec[A]): IO[QueryParamsError, A] = ZIO.fromEither(getAs[A](key))
def getToZIO[A](key: String)(implicit codec: TextCodec[A]): IO[QueryParamsError, A] = ZIO.fromEither(getTo[A](key))

/**
* Retrieves all query parameter values having the specified name, or else
Expand All @@ -137,8 +137,8 @@ final case class QueryParams(map: Map[String, Chunk[String]]) {
* Retrieves all query parameter values having the specified name, or else
* uses the default iterable.
*/
def getAllAsOrElse[A](key: String, default: => Iterable[A])(implicit codec: TextCodec[A]): Chunk[A] =
getAllAs[A](key).getOrElse(Chunk.fromIterable(default))
def getAllToOrElse[A](key: String, default: => Iterable[A])(implicit codec: TextCodec[A]): Chunk[A] =
getAllTo[A](key).getOrElse(Chunk.fromIterable(default))

/**
* Retrieves the first query parameter value having the specified name, or
Expand All @@ -151,8 +151,8 @@ final case class QueryParams(map: Map[String, Chunk[String]]) {
* Retrieves the first typed query parameter value having the specified name,
* or else uses the default value.
*/
def getAsOrElse[A](key: String, default: => A)(implicit codec: TextCodec[A]): A =
getAs[A](key).getOrElse(default)
def getToOrElse[A](key: String, default: => A)(implicit codec: TextCodec[A]): A =
getTo[A](key).getOrElse(default)

override def hashCode: Int = normalize.map.hashCode

Expand Down
15 changes: 2 additions & 13 deletions zio-http/src/main/scala/zio/http/codec/QueryCodecs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package zio.http.codec
import zio.stacktracer.TracingImplicits.disableAutoTrace

private[codec] trait QueryCodecs {
def query(name: String): QueryCodec[String] =
HttpCodec.Query(name, TextCodec.string)
Expand All @@ -26,19 +27,7 @@ private[codec] trait QueryCodecs {
def queryInt(name: String): QueryCodec[Int] =
HttpCodec.Query(name, TextCodec.int)

def queryAs[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] =
HttpCodec.Query(name, codec)

def paramStr(name: String): QueryCodec[String] =
HttpCodec.Query(name, TextCodec.string)

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] =
def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] =
HttpCodec.Query(name, codec)

}
50 changes: 50 additions & 0 deletions zio-http/src/test/scala/zio/http/BodySchemaOpsSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package zio.http

import zio.test._

import zio.stream.ZStream

import zio.schema._
import zio.schema.codec.JsonCodec._

object BodySchemaOpsSpec extends ZIOHttpSpec {

case class Person(name: String, age: Int)
implicit val schema: Schema[Person] = DeriveSchema.gen[Person]
val person: Person = Person("John", 42)
val person2: Person = Person("Jane", 43)
val persons: ZStream[Any, Nothing, Person] = ZStream(person, person2)

def spec = suite("Body schema ops")(
test("Body.from") {
val body = Body.from(person)
val expected = """{"name":"John","age":42}"""
body.asString.map(s => assertTrue(s == expected))
},
test("Body.fromStream") {
val body = Body.fromStream(persons)
val expected = """{"name":"John","age":42}{"name":"Jane","age":43}"""
body.asString.map(s => assertTrue(s == expected))
},
test("Body#to") {
val body = Body.fromString("""{"name":"John","age":42}""")
body.to[Person].map(p => assertTrue(p == person))
},
)
}
36 changes: 18 additions & 18 deletions zio-http/src/test/scala/zio/http/QueryParamsSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -256,25 +256,25 @@ object QueryParamsSpec extends ZIOHttpSpec {
val unknown = "non-existent"
val queryParams = QueryParams(typed -> "1", typed -> "2", invalidTyped -> "str")
assertTrue(
queryParams.getAs[Int](typed) == Right(1),
queryParams.getAs[Int](invalidTyped).isLeft,
queryParams.getAs[Int](unknown).isLeft,
queryParams.getAsOrElse[Int](typed, default) == 1,
queryParams.getAsOrElse[Int](invalidTyped, default) == default,
queryParams.getAsOrElse[Int](unknown, default) == default,
queryParams.getAllAs[Int](typed).map(_.length) == Right(2),
queryParams.getAllAs[Int](invalidTyped).isLeft,
queryParams.getAllAs[Int](unknown).isLeft,
queryParams.getAllAsOrElse[Int](typed, Chunk(default)).length == 2,
queryParams.getAllAsOrElse[Int](invalidTyped, Chunk(default)).length == 1,
queryParams.getAllAsOrElse[Int](unknown, Chunk(default)).length == 1,
queryParams.getTo[Int](typed) == Right(1),
queryParams.getTo[Int](invalidTyped).isLeft,
queryParams.getTo[Int](unknown).isLeft,
queryParams.getToOrElse[Int](typed, default) == 1,
queryParams.getToOrElse[Int](invalidTyped, default) == default,
queryParams.getToOrElse[Int](unknown, default) == default,
queryParams.getAllTo[Int](typed).map(_.length) == Right(2),
queryParams.getAllTo[Int](invalidTyped).isLeft,
queryParams.getAllTo[Int](unknown).isLeft,
queryParams.getAllToOrElse[Int](typed, Chunk(default)).length == 2,
queryParams.getAllToOrElse[Int](invalidTyped, Chunk(default)).length == 1,
queryParams.getAllToOrElse[Int](unknown, Chunk(default)).length == 1,
)
assertZIO(queryParams.getAsZIO[Int](typed))(equalTo(1)) &&
assertZIO(queryParams.getAsZIO[Int](invalidTyped).exit)(fails(anything)) &&
assertZIO(queryParams.getAsZIO[Int](unknown).exit)(fails(anything)) &&
assertZIO(queryParams.getAllAsZIO[Int](typed))(hasSize(equalTo(2))) &&
assertZIO(queryParams.getAllAsZIO[Int](invalidTyped).exit)(fails(anything)) &&
assertZIO(queryParams.getAllAsZIO[Int](unknown).exit)(fails(anything))
assertZIO(queryParams.getToZIO[Int](typed))(equalTo(1)) &&
assertZIO(queryParams.getToZIO[Int](invalidTyped).exit)(fails(anything)) &&
assertZIO(queryParams.getToZIO[Int](unknown).exit)(fails(anything)) &&
assertZIO(queryParams.getAllToZIO[Int](typed))(hasSize(equalTo(2))) &&
assertZIO(queryParams.getAllToZIO[Int](invalidTyped).exit)(fails(anything)) &&
assertZIO(queryParams.getAllToZIO[Int](unknown).exit)(fails(anything))
},
),
suite("encode - decode")(
Expand Down
2 changes: 1 addition & 1 deletion zio-http/src/test/scala/zio/http/codec/HttpCodecSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ object HttpCodecSpec extends ZIOHttpSpec {
val emptyJson = Body.fromString("{}")

val isAge = "isAge"
val codecBool = QueryCodec.paramBool(isAge)
val codecBool = QueryCodec.queryBool(isAge)
def makeRequest(paramValue: String) = Request.get(googleUrl.queryParams(QueryParams(isAge -> paramValue)))

def spec = suite("HttpCodecSpec")(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault {
private val queryParamEndpoint =
Endpoint(GET / "withQuery")
.in[SimpleInputBody]
.query(QueryCodec.paramStr("query"))
.query(QueryCodec.query("query"))
.out[SimpleOutputBody]
.outError[NotFoundError](Status.NotFound)

Expand Down Expand Up @@ -891,7 +891,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault {
HttpCodec
.content[SimpleInputBody] ?? Doc.p("simple input"),
)
.query(QueryCodec.paramStr("query"))
.query(QueryCodec.query("query"))
.outCodec(
HttpCodec
.content[SimpleOutputBody] ?? Doc.p("simple output") |
Expand Down

0 comments on commit 11f13c9

Please sign in to comment.