Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Play frameworkでJSONに余計なフィールドが含まれているときにエラーにするには

Reads[A]にはcomposeWithメソッドがあるため、これを利用して余分なフィールドを検出できる。composeWithは、いったん別のReadsを事前に経由させるメソッド。

Scala 3.3.0、play-json 3.0.4で確認した。

前提

まず、あるcase classと、余分なフィールドを含むJSON、そして余分なフィールドを含まないJSONがあるとする。

import play.api.libs.json._

case class User(name: String, age: Int)

val json: JsValue = Json.parse("""
{
  "name" : "Windymelt",
  "age": 31,
  "like": "beer"
}
""")
val exactJson: JsValue = Json.parse("""
{
  "name" : "Windymelt",
  "age": 31
}
""")

素朴にReadsを作って普通にパースしようとすると以下のような形になる。

import play.api.libs.functional.syntax._

 val userReadBuilder =
  (JsPath \ "name").read[String] and (JsPath \ "age").read[Int]

implicit val userReads: Reads[User] = userReadBuilder.apply(User.apply)
exactJson.as[User]

世の中の大抵のJSONパーサは、入力に対しては寛容なので、余分なフィールドを持つJSONを投入してもパースに成功する。

json.as[User]

厳格なReads[A]

まぁ大人の事情で厳格にパースしたいこともある。余分なフィールドを許さないReadsを構築するには、composeWithが便利だ:

// userReadsとは別のスコープで
implicit val exactUserRead = userReads.composeWith(Reads[JsObject] {
  case j: JsObject
      if (j.keys.filterNot(Set("name", "age").contains)).isEmpty =>
    JsSuccess(j)
  case j: JsObject =>
    JsError(
      s"extra fields: ${(j.keys.filterNot(Set("name", "age").contains))}"
    )
  case _: JsValue => JsError("not an object")
})

println(exactJson.as[User]) // ok
println(json.as[User]) // fail

JsObjectのフィールドを検査し、問題ない場合にのみそのJsObjectを返す検査用Readsを用意しているのがミソだ。composeWithは成功か失敗かも制御できるので事前検査に応用できる。

フィールドを自動的に導出する

フィールドの導出はちょっと手がかかる。手でやるのが困る場合はマクロなどを利用するほかない。

Scala 3ではMirrorがあるし、コンパイルタイムで計算ができるのでcase classのフィールドを持ってくるのは割と簡単だ:

import scala.deriving.Mirror
import scala.compiletime.constValueTuple

inline def labelsOf[A](using p: Mirror.ProductOf[A]) = constValueTuple[p.MirroredElemLabels]
inline def labels = labelsOf[User].toList

Scala 2の場合は、たぶんリフレクションを使うか、厄介なマクロを利用しなければならない・・・(お茶を濁す)

参考文献

users.scala-lang.org

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?