Play framework logo

The Play WS API (like most other HTTP APIs) accepts the query parameters as Seq[(String, String)], a sequence of key-value tuples. This leaves the responsibilities like following to the caller:

  1. If the parameter value is Int, convert it to a string, and add the parameter to query.
  2. If the parameter value is Option[_], check if the value is present, add if so, and not otherwise.
  3. Similar for Try[_]. Add if Success[_], not if Failure[_].
  4. If the parameter value is a Seq[_], create a sequence with key paired with each value individually, and concatenate that to the parameters.

This leads to the following kind of code littered everywhere:

params ++ views.map("views" -> _) // views is Seq[String]

params ++ limit.map(value => "limit" -> value.toString) // limit is Option[Int]

params :+ ("locale" -> context.locale.code)

To address this problem, I created a data type called Params whose usage looks like follows:

val params = Params("views" -> views, "limit" -> limit, "locale" -> localeContext)

which expands to:

val params = Params(
  Param("views", views)(<evidence that Seq[String] can be a parameter value>),
  Param("limit", limit)(<evidence that Option[Int] can be a parameter value>),
  Param("locale", localeContext)(<evidence that LocaleContext can be a parameter value>)
)

Params("views" -> Seq("basic", "price"), "limit" -> Some(4), "q" -> None).value evaluates to Seq("views" -> "basic", "views" -> "price", "limit" -> "4").

The evidence parameters here, evidently, also encapsulate the details of how a value of some data type should be dealt with when being attached to query parameters.

The benefits with this approach:

  1. How various data types are dealt with when being translated to query parameters is now abstracted away. This leads to clearer separation of concerns, and DRY.
  2. It is typesafe. Param("q" -> SomeRandomObject) for example will lead to a compiler error saying SomeRandomObject cannot be used as a parameter value. (Unless you add a CanBeParamValue[_] instance for it.) You can find the full implementation below:
import annotation.implicitNotFound
import language.implicitConversions
import scala.util.{Failure, Success, Try}

case class Params(ps: Param*) {
  lazy val value = ps.foldLeft(Vector.empty: Seq[(String, String)]) { (r, c) => c.attachTo(r)  }

  override def toString = s"Params(${ps.mkString(", ")})"

  override def equals(that: Any) = that match {
    case that: Params => this.value == that.value
    case _ => false
  }

  def ++(that: Params) = Params((ps ++ that.ps): _*)

  def :+(p: Param) = Params((ps :+ p): _*)
}

object Params {
  val empty = Params()
}

abstract class Param {
  def key: String

  type V
  def value: V
  def evidence: CanBeParamValue[V]

  def attachTo(ts: Seq[(String, String)]): Seq[(String, String)] = {
    evidence.attach(ts, key, value)
  }

  override def toString = s"$key -> $value"

  override def equals(that: Any) = that match {
    case that: Param => this.key == that.key && this.value == that.value
    case _ => false
  }
}

object Param {
  def apply[_V](_key: String, _value: _V)(implicit _evidence: CanBeParamValue[_V]) = new Param {
    def key = _key

    type V = _V
    def value = _value
    def evidence = _evidence
  }

  implicit def pairToParam[V](pair: (String, V))(implicit evidence: CanBeParamValue[V]) = {
    pair match {
      case (key, value) => Param(key, value)
    }
  }
}

@implicitNotFound("An object of type ${V} cannot be used as a parameter value in a gateway call.")
trait CanBeParamValue[-V] {
  def attach(ts: Seq[(String, String)], key: String, value: V): Seq[(String, String)]
}

object CanBeParamValue {
  def instance[V](_attach: (Seq[(String, String)], String, V) => Seq[(String, String)]): CanBeParamValue[V] = {
    new CanBeParamValue[V] {
      def attach(ts: Seq[(String, String)], key: String, value: V): Seq[(String, String)] = {
        _attach(ts, key, value)
      }
    }
  }

  implicit val stringCanBeParamValue = {
    instance[String] { (ts, key, value) =>
      ts :+ (key -> value)
    }
  }

  implicit val intCanBeParamValue = {
    instance[Int] { (ts, key, value) =>
      ts :+ (key -> value.toString)
    }
  }

  implicit val stringSeqCanBeParamValue = {
    instance[Seq[String]] { (ts, key, values) =>
      ts ++ values.map(key -> _)
    }
  }

  implicit def optionCanBeParamValue[V](implicit evidence: CanBeParamValue[V]) = {
    instance[Option[V]] { (ts, key, maybeValue) =>
      maybeValue match {
        case Some(value) => evidence.attach(ts, key, value)
        case None => ts
      }
    }
  }

  implicit def tryCanBeParamValue[V](implicit evidence: CanBeParamValue[V]) = {
    instance[Try[V]] { (ts, key, tryValue) =>
      tryValue match {
        case Success(value) => evidence.attach(ts, key, value)
        case Failure(_) => ts
      }
    }
  }
}

Cheers!