(JsPath \ "prop").readNullable[A]
で生成されるReads[Option[A]]で、JSONに"prop"がない(undefined)な場合と、"prop"というプロパティが存在し値がnullの場合を区別する必要がある場合の解決策です。
経緯は後述します。
結論
JsPathを拡張するimplicitクラスを作り、Reads[Option[Option[A]]]を返すreadOptionalというメソッドを作る。返されるReadsの振る舞いは以下の通り
- 読み込むJsPathがundefinedの場合 ->
JsSuccess(None) - 読み込むJsPathの値がnullの場合 ->
JsSuccess(Some(None)) - 読み込むJsPathに値が存在する場合 ->
JsSuccess(Some(Some(A))) - 読み込むJsPathの末尾より前のプロパティが無い場合 ->
JsError("error.path.missing")- (
JsPath \ "a" \ "b"の場合に、末尾の"b"より前の"a"すら存在しない場合)
- (
定義:
implicit class JsPathOps(self: JsPath) {
def readOptional[A](implicit reads: Reads[A]): Reads[Option[Option[A]]] = {
Reads[Option[Option[A]]] { json =>
self.applyTillLast(json).fold(identity, _.fold(
_ => JsSuccess(None),
_ match {
case JsNull => JsSuccess(Some(None))
case js => reads.reads(js).repath(self).map(v => Some(Some(v)))
}
))
}
}
}
※ JSON系の処理まとめているパッケージがあれば、そこのパッケージオブジェクトに定義するといいと思います。
使い方:
// Readsで生成したい型
case class UpdateTaskInput(
name: Option[String],
deadline: Option[Option[LocalDateTime]] // 利用するフィールド。Option[Option[X]]とする。
)
implicit val reads = (
(JsPath \ "name").readNullable[String] and
(JsPath \ "deadline").readOptional[LocalDateTime] // ⇦ 使ってる
)(UpdateTaskInput.apply _)
解説
JsPathのapplyTillLastメソッドは、Either[JsError, JsResult[JsValue]]を返します。引数のJsValueに対してJsPathを適用し以下のように値を返します。
- JsPathの末尾より前のプロパティが無い ->
Left(JsError("error.path.missing") - JsPathの末尾より前はある -> Right
- 末尾はない ->
Right(JsError("error.path.missing") - 末尾も存在する ->
Right(JsSuccess(JsValue))- ここの
JsValueはJsNullかもしれない
- ここの
- 末尾はない ->
identityはScala標準の引数をそのまま返す関数です。arg => argです。
readOptionalをちょっと分かりやすくしたバージョンは以下:
def readOptionalVerbose[A](implicit reads: Reads[A]): Reads[Option[Option[A]]] = {
Reads[Option[Option[A]]] { json: JsValue =>
self.applyTillLast(json).fold( // Either[JsError, JsResult[JsValue]]をfold
(e: JsError) => e, // 末尾より前が無い場合JsError('error.path.missing`)をそのまま返す(identityと同じ)
(jsResult: JsResult[JsValue]) => {
jsResult.fold(
_ => JsSuccess(None), // 末尾が無い(undefined)場合はJsSuccess(None)
(jsValue: JsValue) => jsValue match {
case JsNull => JsSuccess(Some(None)) // 末尾があるがnullの場合はJsSuccess(Some(None))
case js => reads.reads(js).repath(self).map((v: A) => Some(Some(v)))
// ↑JsNull以外の値なのでReads[A]を呼び出してSome(Some(A))にする
}
)
}
)
}
}
経緯
こういった感じのタスク(Task)モデルがありました。
case class Task(name: String, deadline: Option[LocalDateTime])
// ↑任意の期限
このモデル更新のJSON形式のパターンを以下のようにしたい
-
{ "name": "新タスク名", "deadline": null }-> 新しいタスク名を設定して期限を無し(None)にする -
{ "name": "新タスク名" }-> 新しいタスク名を設定してdeadlineはそのまま -
{ "deadline": "2018-01-01T00:00:00" }-> 新しい期限を設定してnameはそのまま
そして問題があるReadsは以下
// タスクアップデート用Input
case class UpdateTaskInput(name: Option[String], deadline: Option[LocalDateTime])
implicit val reads = (
(JsPath \ "name").readNullable[String] and
(JsPath \ "deadline").readNullable[LocalDateTime]
)(UpdateTaskInput.apply _)
この場合、JsPathのreadNullable[T]は、以下のように値を設定するReads[Option[T]]を生成します。
- 対象のプロパティがない(undefined)の場合 ->
None - 対象のプロパティがあり、値がnullの場合 ->
None - 対象のプロパティにnullではない値が存在する場合 ->
Some(Reads[T]の結果)
今回はundefinedとnullを区別したいのでこれだと不十分です。
一応、JsPathにはreadNullableWithDefaultという、デフォルト値をとり、JSONでundefinedならデフォルト値、JSONでnullならNoneとするメソッドがありますが、以下のようなことはしたく無い...
(JsPath \ "deadline").readNullableWithDefault[LocalDateTime](null)
// JSONでundefinedならnull、JSONでnullならNone, 値があったらSome(LocalDateTime)
// うーん。却下w
ということで、Taskの更新の入力値のdeadlineはOption[Option[LocalDateTime]]で表現し、独自でメソッドを定義するしかなくなりました…(不本意)