Improve ergonomics of query parameters and responses
I currently do something like this:
def list(
fields: List[String],
perPage: Int,
page: Int,
sort: List[String]
): ZIO[GatewayEnvironment & UserContext, Throwable, Json] = ???
val route: Http[GatewayEnvironment, Throwable, Request, Response] =
Http.collectZIO[Request] { case request @ Method.GET -> !! / "list" =>
list(
request.url.queryParams.get("fields").toList.flatten,
request.url.queryParams.get("perPage").fold(25)(_.head.toInt),
request.url.queryParams.get("page").fold(1)(_.head.toInt),
request.url.queryParams.get("sort").toList.flatten
)
.flatMap(responseFromJson(_))
.provideSome[GatewayEnvironment](UserContext.fromRequest(request))
}
There's a few issues with this:
- The whole way to get parameters is clunky.
- Parameters need to be converted to the proper type and defaults applied if necessary, this is also clunky
- It would be nice if the response could be automatically converted into a response from a Json (I'm trying out zio-json), or better yet, a response from anything that can be converted to a Json
There are various ways all of this can be done. I sort of like how akka-http does it (https://doc.akka.io/docs/akka-http/current/routing-dsl/directives/parameter-directives/parameters.html), but this is another possible approach that's a bit more verbose, but perhaps more zio-like
val route: Http[GatewayEnvironment, Throwable, Request, Response] =
Http.collectZIO[Request] { case request @ Method.GET -> !! / "list" =>
for {
fields <- request.queryParam[List[String]](name = "fields", default = List.empty, required = true)
perPage <- request.queryParam[Int](name = "perPage", default = 25, required = false)
page <- request.queryParam[Int](name = "page", default = 1, required = false)
sort <- request.queryParam[List[String]](name = "sort", default = List.empty, required = false)
sessionId <- request.headerParam[Option[SessionId]](name = "sessionId", default = None, required = false)
cookie <- request.cookie[Locale](name = "userLocale", default = Locale.US, required = true)
} yield list(fields, perPage, page, sort)
.provideSome[GatewayEnvironment](UserContext.fromRequest(request))
}
Note:
- Automatic conversion of result from Json -> MyObject -> Response
- Automatic conversion from String -> Json -> SessionId in getting header params
- We have queryParams, headerParams, and cookies!
- Don't need to go to request.url, request.xxx is good enough.
Here's an example of something that would look more like akka's way:
val route: Http[GatewayEnvironment, Throwable, Request, Response] =
Http.collectZIO[Request] { case request @ Method.GET -> !! / "list" =>
parameters(
"fields".repeated ? List.empty,
"perPage".as[Int] ? 25,
"page".as[Int] ? 1,
"sort".repeated.optional) { (fields, perPage, sort) =>
headerParams("sessionId".as(SessionIdMarshaller)) {
cookies("userLocale".as(LocalMarshaller)) {
list(fields, perPage, page, sort)
}
}
}
@rleibman Your feedback is noted :) I agree it's not the most convenient right now. Here is what I was thinking — What if we leverage ZIO Schema for this, for eg:
sealed trait Sorting
case object Ascending extends Sorting
case object Descending extends Sorting
case class UserQuery(fields: List[String], perPage: Option[Int], page: Option[Int], sort: Option[Sorting])
def responseFromUserQuery(q: UserQuery): UIO[Response] = ???
Http.collect[Request] { case req @ GET -> !! / "users" / "list" =>
req.queryParams.decode[UserQuery].mapZIO(responseFromUserQuery(_))
}
So decode is going to decode the QueryParams into an Either[String, UserQuery].
I love how that looks, for the one case where you have a one to one correspondence between endpoint and Query case class, but I'm afraid it would not work for the much more common piece meal case (plus you're missing the cookies and headers). But on the other hand we don't always need a case class and can always use a tuple:
req.queryParams.decode[(List[String], Option[Int], Option[Int], String)].mapZIO(???)
I think your solution supports required and optional as well as repeated... how would you support default?
We can similarly add a req.decode it would work something like this —
case class UserRequest(query: UserQuery, name: String, age: Int)
// Http App from UserRequest to UserResponse
val userHttp = Http.collect[UserRequest] {
case userRequest => UserResponse(...)
}
// HttpApp from Request to Response
val http = userHttp @@ (Middleware.decode[UserRequest] ++ Middleware.encode[UserResponse])
The decode and encode methods on Middleware will implicitly take HttpEncoder and HttpDecoder which we can either auto generate for you, OR you can write custom ones like this —
type HttpEncoder[R, E, A] = Http[R, E, A, Response]
type HttpDecoder[R, E, B] = Http[R, E, Request, B]
Now that we have a dependency on ZIO Schema anyway, we can introduce decode and encode methods to QueryParams which use ZIO Schema.