Play FrameworkのFormの値のカスタムMappingを作る

f:id:nextbeat-dev:20170905190650j:plain

nextbeatのエンジニアの國見です。このエントリではPlay FrameworkのFormに関する話をしようと思います。

Play Frameworkには、入力フォームを抽象化したFormというクラスがあります。これは基本的にはHTMLのフォームとドメインモデルをマッピングするために使われます。また、HTMLのフォームだけでなく、POSTで受け取るjsonやGETで受け取るURLパラメータも対応しており、同じFormというクラスで抽象化して扱うことができます。

Formの詳細については本家のドキュメントを参照するのが良いと思います。ここで結構詳しく説明が行われているんですが、1つ個人的にやりたいけど説明されていないものがありました。 それはtextnumberのような、オブジェクトのマッピングではなく値のマッピングを自前で作るやり方です。

言葉だけでは伝わりにくいと思うので、例を書きますと、例えばモデルのIDを直接Int型ではなく、UserIdのような専用のクラスを作るやり方は一般的に行われていると思いますが、問題はこのUserIdマッピングを作りたい時です。おそらく、ドキュメントに書いてある情報を用いてUserIdを受け取る実装を書くと以下のようになるんじゃないでしょうか。

  import play.api.data._
  import play.api.data.Forms._
   import play.api.mvc.Action

  case class UserId(value: Int)

  def hoge = Action { implicit request =>
    val params = single("id" -> number)
    Form(params).fold(
      _ => BadRequest,
      id => {
        val userId = UserId(id) // ここがめんどくさい
        // 必要な処理
        Ok
      }
    )
  }

ここで問題なのは、idの値をIntとして受け取って、手動でUserIdに変換しているところです。おそらくUserIdを受け取る処理は何度も書くことになるでしょう。毎回IntUserIdに変換する処理を書くのは面倒です。

そこで、直接これをUserIdに変換するマッピングがかけないでしょうか。つまり以下のようなコーディングがしたいわけです。

  import play.api.data._
  import play.api.data.Forms._
  import play.api.mvc.Action

  case class UserId(value: Int)

  def hoge = Action { implicit request =>
    val params = single("id" -> userIdMapping) // 直接UserIdに変換するこのuserIdMappingが欲しい。
    Form(params).fold(
      _ => BadRequest,
      userId => {
        // 必要な処理
        Ok
      }
    )
  }

このエントリではこのuserIdMappingを作る方法を紹介します。

では、それの実現方法を調査するために、デフォルトで用意されているtextの実装を見てみましょう。

  /**
   * Constructs a simple mapping for a text field.
   *
   * For example:
   * {{{
   * Form("username" -> text)
   * }}}
   */
  val text: Mapping[String] = of[String]

https://github.com/playframework/playframework/blob/2.5.3/framework/src/play/src/main/scala/play/api/data/Forms.scala#L225-L233

非常に短いですね、このofとは何でしょう。

  /**
   * Creates a Mapping of type `T`.
   *
   * For example:
   * {{{
   * Form("email" -> of[String])
   * }}}
   *
   * @tparam T the mapping type
   * @return a mapping for a simple field
   */
  def of[T](implicit binder: Formatter[T]): FieldMapping[T] = FieldMapping[T]()(binder)

https://github.com/playframework/playframework/blob/2.5.3/framework/src/play/src/main/scala/play/api/data/Forms.scala#L30-L41

implicitなFormatter[T](今回の場合はT=String)を受け取って処理を行っているようです。つまり、userIdMappingを作るためにはFormatter[UserId]を作り、val userIdMapping = of[UserId]とすれば作れるようです。

次にFormatterの実装を見てみましょう。

  /**
   * Default formatter for the `String` type.
   */
  implicit def stringFormat: Formatter[String] = new Formatter[String] {
    def bind(key: String, data: Map[String, String]) = data.get(key).toRight(Seq(FormError(key, "error.required", Nil)))
    def unbind(key: String, value: String) = Map(key -> value)
  }

https://github.com/playframework/playframework/blob/2.5.3/framework/src/play/src/main/scala/play/api/data/format/Format.scala#L57-L63

どうやらこのbindunbindを実装してFormatter[UserId]を作れば良いようです。 Formatterのインタフェースを見てみると

trait Formatter[T] {

  /**
   * The expected format of `Any`.
   */
  val format: Option[(String, Seq[Any])] = None

  /**
   * Binds this field, i.e. constructs a concrete value from submitted data.
   *
   * @param key the field key
   * @param data the submitted data
   * @return Either a concrete value of type T or a set of error if the binding failed.
   */
  def bind(key: String, data: Map[String, String]): Either[Seq[FormError], T]

  /**
   * Unbinds this field, i.e. transforms a concrete value to plain data.
   *
   * @param key the field ke
   * @param value the value to unbind
   * @return either the plain data or a set of errors if unbinding failed
   */
  def unbind(key: String, value: T): Map[String, String]
}

https://github.com/playframework/playframework/blob/2.5.3/framework/src/play/src/main/scala/play/api/data/format/Format.scala#L18-L42

このbindunbindを実装すればいいわけですね。

unbindFormを作ってhtmlを返すときに、Formとhtmlのフォームのマッピングを行うときに用いられるもので、要はその値の文字列表現を返せば良いです。

bindが受け取ったデータを変換する肝になるメソッドで、keyがこれから取得する値のキー、dataが送られてきたデータになります。それを受け取りEither[Seq[FormError], UserId]を返すようにすれば、Formatter[UserId]が出来上がるわけです。

では実装して見ましょう。

  import play.api.data.format.{ Formats, Formatter }

  implicit val userIdFormatter = new Formatter[UserId] {
    def bind(key: String, data: Map[String, String]): Either[Seq[FormError], UserId] =
      Formats.intFormat.bind(key, data).right.map(UserId.apply)

    def unbind(key: String, value: UserId): Map[String, String] = Map(key -> value.value.toString)
  }

内容を説明すると、送られてきたデータから一旦Intで値を取り出し、それをUserIdに変換しています。

これで準備が整ったので、最初のコントローラのメソッドを実装して見ましょう。

  import play.api.data._
  import play.api.data.Forms._
  import play.api.mvc.Action
  import play.api.data.format.{ Formats, Formatter }

  case class UserId(value: Int)

  implicit val userIdFormatter = new Formatter[UserId] {
    def bind(key: String, data: Map[String, String]): Either[Seq[FormError], UserId] =
      Formats.intFormat.bind(key, data).right.map(UserId.apply)

    def unbind(key: String, value: UserId): Map[String, String] = Map(key -> value.value.toString)
  }

  val userIdMapping = of[UserId]

  def hoge = Action { implicit request =>
    val params = single("id" -> userIdMapping) // 動く!
    Form(params).fold(
      _ => BadRequest,
      userId => {
        // 必要な処理
        Ok
      }
    )
  }

実際のコーディングではuserIdFormatteruserIdMappingをベースのコントローラtraitなどの適切な場所に移動する必要があるでしょう。それで完了です。

このエントリではPlay FrameworkでFormの値のMappingを作る方法を紹介しました。ぐぐってもすぐやり方が見つからなかったので、次に同じようなことをやろうとした誰かの助けになれば幸いです。 f:id:nextbeat-dev:20170905190810j:plain nextbeatではドキュメントにないものでもソースコードを読んで自分の要件を実装できるエンジニアを募集しています。

www.wantedly.com