読者です 読者をやめる 読者になる 読者になる

アラタナエンジニアブログ

aratana Engineer's Blog

PlayのJsErrorを使いこなして親切なAPIをつくる

こんにちは。東京で活躍…じゃなくて、活動しているエンジニアの中尾です。
Scala + Playな環境でAPIを書くときのJsErrorの扱い方に関して、いろいろ試してみたので失敗も含めて備忘録として記していきます。前回は技術要素の全くない記事を書いて反省したので、今回はちゃんとコードも載せます。



目的:リクエストのどこが間違っているかまで教えてくれる親切なAPIをつくる

ここでは人の名前と年齢を登録するAPIを考えます。まず、次のように人と名前は集約関係にあるとします。

  case class Person(age:Int, name:Name)

  case class Name(first:String, last:String)

親切なAPIとは、テストコードで次のようなものになります。

  "PersonAPI#register" should {
    "response json validation error" in new WithApplication {
      val Some(result) = route(FakeRequest(POST, "/api/v1/person",
        FakeHeaders(Seq(CONTENT_TYPE -> Seq("application/json"))),
        Json.parse( """{"typo1!!!":24, "name":{"first":"FirstName", "typo2!!!":"LastName"}}""")))
      status(result) mustEqual BAD_REQUEST
      contentAsString(result) mustEqual
        """ {"person.age":[{"msg":"error.path.missing"}],"person.name.last":[{"msg":"error.path.missing"}]}"""
    }
  }

つまり、

{"age":24, "name":{"first":"FirstName", "last":"LastName"}}

をPersonオブジェクトとしてPOSTしたいところを

{"typo1!!!":24, "name":{"first":"FirstName", "typo2!!!":"LastName"}}

と2箇所間違ってしまったとしても

 {"person.age":[{"msg":"error.path.missing"}],"person.name.last":[{"msg":"error.path.missing"}]}

というように、personのageキーとpersonのnameのlastキーに対応する値が必要なのにないよ!とResponse Bodyで返して欲しいわけです。
Personオブジェクトに関する間違いも、Nameオブジェクトに関する間違いもすべて一度に返してくれていますね。





引っかかったところ:検出場所の異なるJsErrorをどのようにして、ひとつにするのか

JsonからPersonやNameオブジェクトへのパース時にエラーが起きた時に返される型がJsErrorです。JsErrorの定義を見てみると

case class JsError(val errors : Seq[Tuple2[JsPath, Seq[ValidationError]]]) .....

というように、一連のパースで起きたすべてのエラーに関して、どの位置でどのエラーが起きたかを返してくれます。

今回のAPIエラーを表現するためにはこのJsError型を使うのですが、PersonオブジェクトとNameオブジェクトそれぞれのパースで生じたJsErrorをどのようにひとつにするかわかりませんでした。





失敗例:なぜかfor~yield式をつかってしまった

ひとつに積み重ねていくってScalaでいうとfor~yield式ぽいな、という全く当たらない直感が働いた結果、勢いだけで書いてしまったコードがこちらです。くれぐれも参考にしないでください。

  implicit object NameJsonFormatter extends Format[Name] {
    def reads(json: JsValue): JsResult[Name] = {
      for {
        first <- (json \ "first").validate[String]
        last <- (json \ "last").validate[String]
      } yield new Name(first, last)
    }

    def writes(name : Name) : JsValue = {
      Json.obj(("first", name.first), ("last", name.last))
    }
  }

  implicit object PersonJsonFormatter extends Format[Person] {
    def reads(json: JsValue): JsResult[Person] = {
      for {
        age <- (json \ "age").validate[Int]
        name <- (json \ "name").validate[Name]
      } yield new Person(age, name)
    }

    def writes(person : Person) : JsValue = {
      Json.obj(("age", person.age), ("name", person.name))
    }
  }

Format[A]トレイトをMixinして、A型からJsonへのパースをwritesで、JsonからA型へのパースをreadsで実装してあります。validate[A]の戻り値はJsSuccess[A]またはJsError[Nothing]が返ってくるので、その中身を取り出し……みたいな"イメージ"だったのですが間違っていたのでこのへんでやめます。

ちなみにこちらの実装でAPIとしてのレスポンスはこのようになりました。

{"obj":[{"msg":"error.expected.jsnumber"}]}

正常系のテストもちゃんと通るし、エラーも返ってくるんだけどなんか惜しい。エラーのパスとエラーの種類がちゃんと取れていないですね。そもそも間違い2つあるはずなんだけど。これでは使いやすいAPIはつくれません。





成功例:ちゃんとドキュメントを読んだ

惜しかったですが気を取り直してリベンジです。つぎは直感ではなくちゃんと公式ドキュメントを参考にしました。

  implicit val NameJsonFormatter: Format[Name] = (
    (__ \ "first").format[String](minLength[String](1)) and
      (__ \ "last").format[String]
    )(Name.apply _, unlift(Name.unapply))

  implicit val PersonJsonFormatter: Format[Person] = (

    (__ \ "age").format[Int] and
      (__ \ "name").format[Name]
    )(Person.apply _, unlift(Person.unapply))


こちらのformat[A]をつかった書き方では両方向のパースができます。シンプルでいいですね。バリデーションの条件としてminLength[String](1)などのように指定するとパースと同時にチェックしてくれます。

こちらの実装のAPIのレスポンスはこのようになました。

{"person.age":[{"msg":"error.path.missing"}],"person.name.last":[{"msg":"error.path.missing"}]}

間違ったとしても次はどこを修正してリクエストを投げればいいのかわかるようになりました。めでたしめでたし。





まとめ

Play + Scalaを日々どのように遠回りをして学んでいるのかをご紹介しました。
今回は最初からちゃんとドキュメント読めよって話でしたが、ときには自分の直感を信じてサンプルを実装してみて、なぜ動かないのかを考えることも言語やフレームワーク習得のためには必要な気もします。


失敗例、成功例ともにサンプルはこちらです。github.com