zio-http icon indicating copy to clipboard operation
zio-http copied to clipboard

Improve ergonomics of query parameters and responses

Open rleibman opened this issue 3 years ago • 5 comments

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.

rleibman avatar Feb 10 '22 17:02 rleibman

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 avatar Feb 10 '22 19:02 rleibman

@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].

tusharmath avatar Feb 11 '22 12:02 tusharmath

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?

rleibman avatar Feb 11 '22 16:02 rleibman

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]

tusharmath avatar Feb 15 '22 09:02 tusharmath

Now that we have a dependency on ZIO Schema anyway, we can introduce decode and encode methods to QueryParams which use ZIO Schema.

jdegoes avatar Mar 17 '23 23:03 jdegoes