Play Framework の QueryStringBindable について

**Play Framework** の [QueryStringBindable](https://www.playframework.com/documentation/2.7.x/ScalaRequestBinders#QueryStringBindable) の話をしようと思います。

# What is QueryStringBindable？

GET リクエスト の `QueryString` を ケースクラスにバインドする便利機能です。
以下のように `Controller` の引数にケースクラスを取ることができます。

```scala:SearchController.scala
package controllers

import javax.inject.Inject
import play.api.mvc.{
  AbstractController,
  Action,
  AnyContent,
  ControllerComponents
}

class SearchController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {

  def get(r: SearchRequest): Action[AnyContent] = Action {
    // なんかいろいろ検索したりとか...
    Ok("Success")
  }
}

case class SearchRequest(
  interval: Interval,
  dayOfWeeks: Set[DayOfWeek],
  note: Option[Note],
  limit: Option[Limit]
)
```

# Motivation of using

`Bindable` を使う主なモチベーションは以下のような感じです。

- `QueryString` にパラメータの追加があったときに `routes`, `Controller` のシグネチャに変更が及ばない。
- ケースクラス の生成を `Controller` でやらずに済む。
- 不備のある `QueryString` のエラーハンドリングを集約できる。

一覧するAPIなんかを開発してるとフィルタリングすることがよくあると思いますが、
複数のパラメータを集約したケースクラスにバインドして `Controller` で受け取れるのはうれしみがあります。

# Sample Code

実装例の紹介です。

## Case Class

例えば以下のような検索条件を表すオブジェクトにバインドしたいとします。

```scala
import java.time.{Clock, DayOfWeek, Instant, ZonedDateTime}

/**
 * 検索条件
 * @param interval 対象期間
 * @param dayOfWeeks 曜日(月,火,水,...
 * @param note 検索文字列
 * @param limit 上限件数
 */
case class SearchRequest(
  interval: Interval,
  dayOfWeeks: Set[DayOfWeek],
  note: Option[Note],
  limit: Option[Limit]
)

case class Interval(
  from: ZonedDateTime,
  to: ZonedDateTime
)
case class Note(v: String)
case class Limit(v: Long)
```

`Interval` は予め以下のような `Factory` を作成しておくと後々便利です。

```scala
object Interval {

  // long から ZonedDateTime へのパース
  def parseZDT(v: Long)(implicit c: Clock): ZonedDateTime =
    ZonedDateTime.ofInstant(Instant.ofEpochMilli(v), c.getZone)

  // from, to どちらも指定があった場合に使う
  def fromTo(from: Long, to: Long)(implicit c: Clock): Interval =
    new Interval(parseZDT(from), parseZDT(to))

  // form のみ指定の場合に使う
  def fromToToday(from: Long)(implicit c: Clock): Interval =
    Interval(parseZDT(from), ZonedDateTime.now(c))

  // to のみ指定の場合に使う
  def fromUnixEpochTo(to: Long)(implicit c: Clock): Interval = 
    Interval(ZonedDateTime.ofInstant(Instant.EPOCH, c.getZone), parseZDT(to))

  // どちらも指定がない場合は全期間を対象とする
  def all()(implicit c: Clock): Interval =
    Interval(ZonedDateTime.ofInstant(Instant.EPOCH, c.getZone), ZonedDateTime.now(c))

  def apply(from: Option[Long], to: Option[Long])(implicit c: Clock): Interval =
    (from, to) match {
      case (Some(f), Some(t)) => fromTo(f, t)
      case (Some(f), None) => fromToToday(f)
      case (None, Some(t)) => fromUnixEpochTo(t)
      case _ => all()
    }
}
```

## QueryStringBindable

`SearchRequest` は階層構造になっているので、各項目の `Bindable` を予め実装し、使い回しが効くようにします。`Interval` と `DayOfWeek` への `Bindable` を作ります。

#### IntervalBindable

```scala:IntervalBindable.scala
import java.time.{Clock, ZonedDateTime}

import cats.data.EitherT
import cats.implicits._
import controllers.Interval
import play.api.mvc.QueryStringBindable

trait IntervalBindable extends BindableSupport {

  private type Bindable[A] = QueryStringBindable[A]
  private type Params = Map[String, Seq[String]]
  private type Bound[A] = Option[Either[String, A]]
  private type ZDT = ZonedDateTime

  private implicit lazy val clock: Clock = Clock.systemDefaultZone()

  implicit def intervalBinder(
      implicit binder: Bindable[Long]): Bindable[Interval] =
    new Bindable[Interval] {

      override def bind(key: String, params: Params): Bound[Interval] =
        Some({
          implicit val p: Params = params
          for {
            from <- bindToRightOption[Long]("from")
            to <- bindToRightOption[Long]("to")
          } yield Interval(from, to)
        })

      override def unbind(key: String, value: Interval): String =
        Seq(
          binder.unbind("from", value.from.toInstant.toEpochMilli),
          binder.unbind("to", value.from.toInstant.toEpochMilli)
        ).mkString("&")
    }
}

// ヘルパーオブジェクト
trait BindableSupport {

  private type Bindable[A] = QueryStringBindable[A]
  private type Params = Map[String, Seq[String]]
  private type Bound[A] = Option[Either[String, A]]

  // QueryStringBindable[A]#bind(...): Option[Either[String, Option[A]]] が None の場合に
  // 展開して Right(None) にする。 → 以外とこれやりたいユースケースが多い。
  def bindToRightOption[A](key: String)(
    implicit params: Params,
    bindable: Bindable[A]): Either[String, Option[A]] =
    EitherT(bindable.bind(key, params))
      .map(Option(_))
      .value
      .getOrElse(Right(None))
}
```

`Option[Either[String, Option[A]]]` を取り回すことが多いので [Cats の EitherT](https://typelevel.org/cats/datatypes/eithert.html) を使ってます。


#### DayOfWeekBindable

```scala:DayOfWeekBindable.scala
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Locale

import cats.data.EitherT
import cats.implicits._
import play.api.mvc.QueryStringBindable

import scala.util.Try

trait DayOfWeekBindable {

  private type Bindable[A] = QueryStringBindable[A]
  private type Params = Map[String, Seq[String]]
  private type Bound[A] = Option[Either[String, A]]

  implicit def dayOfWeekBinder(
      implicit binder: Bindable[String]): Bindable[DayOfWeek] =
    new Bindable[DayOfWeek] {

      override def bind(key: String, params: Params): Bound[DayOfWeek] = {
        val eitherT = for {
          str <- EitherT(binder.bind("dayOfWeek", params))
          dayOfWeek <- EitherT(
            Option(
              Try(DayOfWeek.valueOf(str.toUpperCase)).toEither
                .leftMap(_.getMessage)))
        } yield dayOfWeek
        eitherT.value
      }

      override def unbind(key: String, value: DayOfWeek): String =
        binder.unbind("dayOfWeek",
                      value.getDisplayName(TextStyle.FULL, Locale.JAPAN))

    }
}
```

#### SearchRequestBindable

メインの `SearchRequest` への `Bindable` です。
予め作った `Interval` と `DayOfWeek` の `Bindable` は `QueryStringBindable[A]` のパラメータに指定することで利用が可能になります。

```scala:SearchRequestBindable.scala
import java.time.DayOfWeek

import cats.data.EitherT
import cats.implicits._
import controllers._
import play.api.mvc.QueryStringBindable

trait SearchRequestBindable extends BindableSupport {

  private type R = SearchRequest
  private type Params = Map[String, Seq[String]]
  private type Bindable[A] = QueryStringBindable[A]
  private type Bound[A] = Option[Either[String, A]]

  implicit def searchRequestBindable(
      implicit
      longBinder: Bindable[Long],
      stringBinder: Bindable[String],
      intervalBindable: Bindable[Interval],
      dayOfWeekBinder: Bindable[Seq[DayOfWeek]]
  ): Bindable[R] = new Bindable[R] {
    override def bind(key: String, params: Params): Bound[R] = {
      implicit val p: Params = params
      for {
        interval <- EitherT(intervalBindable.bind(key, params))
        dayOfWeek <- EitherT(
          Option(bindToRightOption[Seq[DayOfWeek]]("dayOfWeek")))
        note = EitherT(stringBinder.bind("note", params)).valueOrF(_ => None)
        limit <- EitherT(Option(bindToRightOption[Long]("limit")))
      } yield
        SearchRequest(
          interval,
          dayOfWeek.getOrElse(Seq.empty).toSet,
          note.map(Note),
          limit.map(Limit)
        )
    }.value

    override def unbind(key: String, r: R): String = {
      def unbind[T](key: String, s: Option[T])(
          implicit b: Bindable[T]): Option[String] =
        s.map(f => b.unbind(key, f))

      val keys = Seq(
        unbind(key, Some(r.interval)),
        unbind(key, Some(r.dayOfWeeks.toSeq)),
        unbind("note", r.note.map(_.v)),
        unbind("limit", r.limit.map(_.v)),
      )
      keys.flatten.mkString("&")
    }
  }
}
```

## build.sbt

コンパイル時に `routes` が参照できるようにするために `build.sbt` のプロジェクトに `routesImport` を設定してクラスパスを通してやる必要があります。クラスパスを集約するために以下のようなオブジェクトを作りました。

```scala:Implicits.scala
package bindable

object Implicits
    extends SearchRequestBindable
    with IntervalBindable
    with DayOfWeekBindable
```

`build.sbt` の `settings` に `routesImport` 追加します。

```scala.build.sbt
.settings(routesImport += "bindable.Implicits._")
```

以上で、下準備は完了です。

## Controller & routes

`Controller` と `routes` から `SearchRequest` が取れるようになります。

```scala:SearchController.scala
// 再掲

package controllers

import javax.inject.Inject
import play.api.mvc.{
  AbstractController,
  Action,
  AnyContent,
  ControllerComponents
}

class SearchController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {

  def get(r: SearchRequest): Action[AnyContent] = Action {
    // なんかいろいろ検索したりとか...
    Ok("Success")
  }
}

case class SearchRequest(
  interval: Interval,
  dayOfWeeks: Set[DayOfWeek],
  note: Option[Note],
  limit: Option[Limit]
)
```

```:routes
GET    /search    controllers.SearchController.get(r: controllers.SearchRequest)
```

💪💪💪

# 所感

ちょっとコード量が多い（てか、かなり多い？）気もするけど `Controller` のシグネチャにケースクラスを取れるのは嬉しいことです。`Option[Either[A, B]]` を取り回すことが多いので [EitherT](https://typelevel.org/cats/datatypes/eithert.html) とか使うとお気持ちラクになれるかも？

一点気に食わないのが `routesImport` の設定について公式ページに説明が無さそうなこと。書いといて欲しいなー（見逃してるだけ...？


# 参考文献

- [Play Framework: ScalaRequestBinders#QueryStringBindable](https://www.playframework.com/documentation/2.7.x/ScalaRequestBinders#QueryStringBindable)
- [Play 2.x の QueryStringBindable, PathBindable について](http://tototoshi.hatenablog.com/entry/20130216/1360985707)
- [Route binding custom classes in Play! Framework
](https://medium.com/kifi-engineering/route-binding-custom-classes-in-play-framework-c6921cfe83c2)