PlayFramework
TypeScript

Play と戯れていたらなぜか構文解析をしていた件について

「Opt Technologies Advent Calendar 2017」14日目の記事です。
13日目は @hiroshistRDSでr3とm4を比較してみた です。
15日目は @sisisin 自己組織化されたスクラムチームを作るためにスクラムマスターとしてやったこと 予定です

はじめに

最近 PlayFramework1 と戯れる機会があったのですが、気がついたら構文解析をしていたのでそのことについてまとめておきます。

TL;DR

API リクエストのバリデーションの際に構造が一部失われてしまうので、それを復元するために構文解析をせざるを得なかった話。
https://github.com/watiko/json-path-converter

本文

起きたこと

以下のような若干複雑な構造の API を作ったとします。(コードは単一ファイルで動くように書いたサンプル)
userMapping を見ると全てのフィールドが必須なので、TestMain.main のなかで作っているような不完全なデータがリクエストとして渡ってくるとバリデーションエラーになります。

// Play 2.6.9 で確認
import play.api.data.{Form, FormError, Mapping}
import play.api.data.Forms._

case class Address(country: String, prefecture: String)
case class User(name: String, favoriteNumbers: List[Int], address: Address)

class FakePlayApp {
  lazy val userForm = Form(userMapping)

  val userMapping: Mapping[User] =
    mapping(
      "name" -> text,
      "favoriteNumbers" -> list(number),
      "address" -> mapping(
        "country" -> text,
        "prefecture" -> text
      )(Address.apply)(Address.unapply)
    )(User.apply)(User.unapply)

  def api(data: Map[String, String]) =
    userForm.bind(data).fold(
      f => println(formErrorsToMap(f.errors)),
      println
    )

  def formErrorsToMap(formErrors: Seq[FormError]): Map[String, String] = {
    formErrors.foldLeft(Map[String, String]()) {
      (map, e) => map + (e.key -> e.message)
    }
  }
}

object TestMain {

  def main(args: Array[String]): Unit = {
    val app = new FakePlayApp

    app.api(Map(
      "name" -> "hello",
      "favoriteNumbers[0]" -> "str",
      "address" -> null
    ))
  }

}

(favoriteNumbers[0] をみた瞬間に何かを感じ取ってもらえるかも)

実際にこのコードを実行して見ると以下のような出力を得ることができます。

Map(favoriteNumbers[0] -> error.number, address.country -> error.required, address.prefecture -> error.required)

お分りいただけたでしょうか? そう、内部表現の時点で JSON としての構造がフラットに表現されているのです…… (辛い)
もちろんクエリパラメータも扱ったりする上でしょうがないのかもしれないですが……

対処

このデータを構造的に扱うには誰かが組み立て直さないといけませんが、諸事情からこの責務はブラウザ側で負うことにしました。そこで作ったのが watiko/json-path-converter です。

具体的な動作イメージはテストをみていただくのが手っ取り早いと思うので一部抜粋します。

it('オブジェクトのネストと配列両方存在しても適切に変換できる', () => {
  const from = {
    'object.nested': 'object',
    'array[0]': 'array',
  };

  const to = {
    object: { nested: 'object' },
    array: [ 'array' ],
  };

  assertConvert({ from, to });
});

詳細はソースコードなどに譲るとしてここではオブジェクトのプロパティを元に構造を作り直しているのがわかっていただければ十分です。

実装

大きく以下の2段階に別れています

  • パース: プロパティをパースして構造を表現するデータをつくる
  • 変換: パースして得たデータを元に構造を作り直す

パース

実はここはそんなに手間がかかっておらず jneen/parsimmon というパーサコンビネータを使ってサクッと作ってしまいました。むしろ以下に示した文法(2017/12/13時点)を書き出すことの方が時間を使ったかも(多分気のせい)

json-path -> propName ( prop / index )*
prop      -> '.' propName
index     -> '[' number ']'
propName  -> [a-zA-Z0-9] +
number    -> '0' / [1-9] [0-9]*

propName should follow https://tools.ietf.org/html/rfc7159#section-7 but we didn't to keep simplicity.

変換

こちらは難産でした、ある手順にしたがってオブジェクトの階層を作り、最後に値を設定しなければいけないので最初は全く方策が思いつかず途方にくれていました。ぜひこれを読んでいる型にも考えて欲しいくらい悩みました(今となっては記憶が怪しいけれども)

しばらく考えたのち、パース時点で判明した構造に対して値を設定するセッターを作ればいけそうだと気づいたのであとはその方針で黙々と作るだけでした。

最後に

実は作ったは良いものの何にも組み込んでおらず2 npm publish さえしていないという有様なので早急になんとかしたい所存です。


  1. https://playframework.com/ 

  2. 特定の構造にしか対応していないバージョンを雑に作って直接使っている