Validationのことを考えてたらFunctional Validationという考え方に行き着いた

問題意識

  1. Rails の validation の基本は ActiveModel::Validations だが、Strong Parameters を別に書くのは二重化してしまう 部分 がある
  2. さらに API クライアントみたいな SPA みたいなものがあると三重化してしまう
  3. Conditional Validation はやめたい

Rails内でvalidationをDRYにするdry-validation

例えば Laravel には独立した Validator があるし、Hanami::Validations も独立している。

で、Hanami::Validations は dry-validation を利用している。

dry-rb - dry-validation - Introduction

まず 1 の Rails 内で validation が DRY でないということで、そのまんまの名前の dry-validation を代わりに使うことを検討した。

独自の DSL で Schema を定義し、それで data を validate する。

Schema = Dry::Validation.Schema do
  required(<attr>).filled
  ..
end

result = Schema.(data).inspect

こんな感じ。

例えば Model では AciveModel::Validations を一切定義せずに

def valid?
  result = Schema.(self.attributes).inspect

  if result.errors.size == 0
    true
  else
    errors = result.errors
    false
  end
end

とすれば雑な感じでは validation を置き換えることができる。(たぶん考慮しなきゃいけないことは足りてない。)Controller では

before_action do
  params.permit!
end

def <action>
  result = Schema.(params.to_h).inspect

  if ( result.erros.size > 0 )
    head 400
  else
    head 200
  end
end

みたいな感じになると思う。宣言的に書けず分岐が一つ深くなるのはイヤということであればなんか raise する guard 節でもよい。

 raise NankaError if Schema.(params.to_h).failure?

ただし、標準の機能ではないし、許可されていない値を突っ込もうとするといきなり例外で死ぬ、みたいな「うっかり防止」感は弱いので、まず「Mass Assignment 脆弱性ってのがあってね」といった基本をしっかり押さえてから切り替えた方がよさそう。

ActiveModel::Validations はともかく Strong Parameters の記述はちっとも分かりやすさがなく、もう「とりあえずホワイトリストにしか使わない」感じはあって全然嬉しくないなーと思っていたので、独立した Validator を使うのは一時的には道具は増えるけど、いろんな書き方を覚えられなくて時間を浪費するよりはよさげだなぁという感触。

ただし許可されていないパラメータは明示的に禁止しないとダメで、書き方としては

optional(:id).value(:empty?)

みたいな感じになる。ここが少し罠っぽい1

もう一つ、Schema 定義が独立すると当然だけど「登場人物が増えるとどっちをテストすればよいのか迷う問題」が発生し得る。例えば Schema に対してテストコードを書くか、Model に対して書くか、といった問題。

まぁ自分がやるなら特に難しくは感じてなくて、

  • Model に対しては本当に validation が間違いなく実行されるかどうか
  • 詳細は Schema に対して

テストコードを書くという作り方をすると思う。そこが「関心」なので。

そういうサンプルが手近にないと Schema のテストはバッチリしてたけど実際に validation が意図通りに実行されていないことが e2e のテストの終盤で見つかりました、みたいなことは起きかねないかもなぁと思っている。丁寧に commit を分けたサンプルがあるといいんだろう。

cf.

JSON Schemaはprmdのサンプルはクソ面倒だけどまぁまぁか?

JSON Schema | The home of JSON Schema

JSON Schema は狙いがいくつもあって非常に分かりにくいし、prmd init するとサンプルがてんこ盛りで分かりにくい。分かりにくい。

ただまぁ、言語非依存で schema 定義を行えるのでサーバとクライアントで共有できる、クライアントの言語を問わない、といったメリットは間違いなくある。

とりあえず我慢して作ってみて Vue.js にぶっこんでみた。

使った npm は tdegrunt/jsonschema: JSON Schema validation

実際に書くとこんな感じ。

<template>
  <Error :errors=errors v-if=errors />
  ..
</template>

<script>
import {validate} from 'jsonschema'
import Schema from 'schema.json'
import Error from 'Error.vue'

components: {
  Error
},
data() {
  ..
},
computed: {
  errors() {
    return validate(this.$data, Schema).errors
  }
}
</script>

ここでは computed に入れてあるが、実際には validate を走らせるタイミングが大事になってくるはずなので、あくまで上のコードはイメージ。また、Error の component が独立してるのは「大雑把にViewコンポーネントから責務をひっぺがしていくフロントエンド設計」で考えていた話。

JSON Schema はよく JSON Hyper Schema の文脈で語られたり API サーバの validation とクライアントの validation の両方に適用できるという意味で使われたりしている。

が、個人的には

  • API サーバの validation とクライアントの Model 相当のものの validation は意味が違うのでは?
  • むしろ API サーバ上の Model の validation とクライアントの Model の validation が近いのでは?
  • サーバの Model は状態を持つので条件によって validation ルール変わったりする(Conditional Validation)よね?
  • ということは JSON Schema は何種類必要なの?
  • でも当然重複もあるよね?
  • Schema 表現としてどの程度の自由度があるの?

など、非常に気になることが多い。これは次の話に続く。

cf.

Conditional ValidationではなくFunctional Validationがよさそう?

Conditional Validation という考え方が実際にはよくあって、例えばブログ一つ取っても

  • 下書き保存の際には何が欠けててもよい
  • 「公開」時にはタイトルもカテゴリも本文もアイキャッチの画像も全部埋まってないとダメ

みたいなことがあちこちで生まれる。

上の例だと素朴には「公開」かどうかを表す attribute をまず見て、それに応じて validation が追加設定されるという感じになりがち。イメージとしては以下のようになる。

validates :body presence: true, if: -> { public? }
validates ...                 , if: -> { public? }
...

だが、これはやめたいと思っている。理由は

そもそもの validation の意味、field の内容を見て追加される validation の意味、意図がコードから読み取りにくい

から。

ではどうするかというと Validator というか Schema というか、それに名前を付けて、

この条件が成り立つ時はこういう意図のこの名前の validation が行われる

という形にすべき。validation が複雑になればなるほど意図が大切になる。

しかし、ここでも schema 定義の多重化が起きてしまう。そこで「Schema の merge ができれば便利なのではないか?」と思ったら dry-validation にはしっかりその機能があった。

Extending a validation schema - Support / dry-validation - dry-rb discussion forum

では JSON Schema はどうだろうか?と思ったが、ここでハタと困ってしまった。そもそも JSON Schema 自体が分かりやすいとは言えないのにこいつを merge とかできるのか?というかそんな機能を持ってる JSON Schema ライブラリは存在しないように見える…。

ここでしばらく悩んだが、

を読んでいたのと、最近 Functional Reactive Programming のことを考えていたので、JSON Schema 自体に詳しくなろうとするより考え方を変えた方が良いなと思い直し、

Schema を merge するのではなく Validation の結果を compose すればよいのでは?

と思いついた。

実現方法は簡単で、複数の schema を使った複数回の validation の結果を一つにまとめておく result オブジェクトがあればよい。しかもこれなら JSON Schema での validate だろうが dry-validation の validate だろうが何も気にしなくてよい。Schema の定義方法も Validation Engine の実装も何もかも違っていてよい。これなら JSON Schema と dry-validation で Schema が重複してしまうといったことも気にする必要がない。Rails 以外だろうと他にどんなツールが増えようと、ずっと使える考え方だ。

「なんてこった天才かオレ」と思ったけど、やはりすでにこの考え方をしている人はいます。そういうことかー。

A Functional Approach to Data Validation

※ protocol に依存せずに結果の errors が欲しいので、発想としてはたまによく gem で見かける :context を使えばよいのかな?

実は… ActiveModel::Validations だけならとっくにできますhttps://railsguides.jp/active_record_validations.html#validates-with が、今回の発見はどんな schema validator でも組み合わせようと思えばできること。です。

cf.

  1. Mass Assignment脆弱性対策のためだけに許可する params を列挙するだけに使うという使い方は残してもよいのかもしれない。でもそのためだけに private メソッドを使うのもイマイチなんだよねぇ。Action メソッドの中に宣言的に書けるならいいと思う。でも Action がメソッドだと難しいよね、これ。Hanami のように Action が class になってるとよさそう。 

More