ActiveModelでパラメータのバリデーションをする

# はじめに
APIだけ開発して、期待していないJSONでリクエストしたら想定外の挙動になったので、その挙動を紹介します。

---

## 期待しない型で送ると500エラーになる
次のパラメータを期待したAPIを考えます。

* name
  * 文字列型
  * 必須
  * 1文字以上、10文字以下
* mail
    * 文字列型
    * 必須
    * メールアドレスの正規表現
* age
    * 数値型
    * 必須
    * 0以上の整数


こんな感じのフォームを作ってバリデーションするかと思います。

```email_validator.rb
class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i.match?(value)
      record.errors.add(attribute, 'must be an mail address')
    end
  end
end
```

```sample_form.rb
class SampleForm
  include ActiveModel::Model
  attr_accessor :name, :mail, :age

  validates :name, presence: true, length: { maximum: 10 }
  validates :mail, presence: true, email: true
  validates :age: presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
```


```rb
form = SampleForm.new(name: nil, mail: 'hoge', age: 1.5)
form.valid? # false
form.errors.to_hash # {:name=>"can't be blank", :mail=>"must be an mail address", :age=>"must be an integer"}
```

よしよし、それぞれちゃんとバリデーションされていますね。
ちょっとイジワルして別の型で実行してみましょう。

```rb
form = SampleForm.new(name: 15, mail: 3, age: '15')
form.valid? # TypeError (no implicit conversion of Integer into String)
```

不正なパラメータなので`form.valid?`がfalseになって400エラーを返したいところでしたが、バリデーション時にエラーになってしまいました。APIのレスポンスは500エラーになることでしょう。

これは`EmailValidator`でvalueの型を確かめずに比較をしていたのが原因のようです。

カスタムバリデータでは型チェックを最初にした方がよさそうです。

```diff:email_validator.rb
class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
-   unless /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i.match?(value)
+   unless value.is_a?(String) && /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i.match?(value)
      record.errors.add(attribute, 'must be an mail address')
    end
  end
end
```

改めてバリデーションを実行してみましょう。

```rb
form = SampleForm.new(name: 15, mail: 3, age: '15')
form.valid? # false
form.errors.to_hash # {:mail=>"must be an mail address"}
```

メールもちゃんとバリデーションされました。ですが、期待している型ではないnameとageはエラーになっていないようです。

ActiveRecordだったらこのまま`create!`しても良い感じに保存してくれますが、バリデーションしたパラメータで何かするときには500エラーになりそうです。型をチェックするバリデータを作っても良いかもしれません。

```rb
user = User.create!(name: 15, age: '15')
user.name # "15"
user.age # 15
```

```type_validator.rb
class TypeValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless value.is_a?(options[:type])
      record.errors.add(attribute, 'is invalid')
    end
  end
end
```

```rb
form = SampleForm.new(name: 15, mail: 3, age: '15')
form.valid? # false
form.errors.to_hash # {:name=>"is invalid", :mail=>"must be an mail address", :age=>"is invalid"}
```

型チェックに関しては[validates_type](https://github.com/yez/validates_type)というgemもあるようです。

### 追記

クエリパラメータの値は文字列で受け取るので、バリデーション時点では文字列型も許容しないとバグになるかもしれません。バリデーションを通った直後に必要な型に変換した方がよさそうです。

```rb
# /users?page=1
params[:page] # "1"
form = UserForm.new(params)
form.valid? # true
params[:page] = params[:page].to_i
params[:page] # 1
```

---

## allow_nil の罠（自爆）

以下のパラメータのバリデーションを考えましょう。

* id_array
    * 数字の配列
    * 必須（`nil`はダメ）

`presence: true`とすると空配列もエラーになってしまいます。
そこで自分は~~なんとなくで~~次のように書きました。

```array_validator.rb
class ArrayValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless value.nil? || value.is_a?(Array)
      record.errors.add(attribute, 'is invalid')
    end
  end
end
```

```sample_form.rb
class SampleForm
  include ActiveModel::Model
  attr_accessor :id_array

  validates :id_array, allow_nil: false, array: true
end
```

さっそくnilを入れてみましょう

```rb
form = SampleForm.new(id_array: nil)
form.valid? # true
```

アレ？バリデーションが仕事してくれませんでした。。。

[Railsガイド](https://railsguides.jp/active_record_validations.html#allow-nil)を読んでみると、`allow_nil: true`にしたときにバリデーションをスキップするのであって、別に`false`にしたからって`nil`でエラーにしてくれるわけではないようです。


[numericalityオプションの仕様](https://railsguides.jp/active_record_validations.html#numericality)

>デフォルトでは、numericalityのnil値は許容されません。allow_nil: trueオプションでnil値を許可できます。

と同様にカスタムバリデータでは`nil`ではエラーになるようにしておいて、`nil`を許容する場合に`allow_nil: true`を指定するのがよさそうです。

---

## パラメータを全然違う型で送ったのにパラメータエラーにならない

配列型を期待している`some_array`というパラメータがあるとします。このパラメータはnullでも問題なくAPIを実行できるとします。
ここに`hoge`という文字列を入れてリクエストすると、以下のようななります。


```rb
# params = {
#   some_array: 'hoge'
# }

permitted_params = params.permit(
  some_array: []
).to_h

permitted_params # {}

form = Sampleform.new(permitted_params)

# some_arrayに文字列を入れてリクエストしているのでエラーになって欲しいが、permitによってなくなっているのでtrueになる
form.valid?

# some_array にデフォルト値があると、そのまま作成できる
User.create!(permitted_params)

```

配列であるべきところに文字列を指定したのでパラメータエラーになって欲しかったのですが、ユーザ作成に成功してしまいました。

最初に `params` に期待しているキーが全て存在することを確認すればいい気がしますが、自分には良い感じの実装方法が思いつきませんでした。対策を知っている方がいればコメントお願いします・・・

---

# おわりに
期待していない型でやってくることも、nullチェックも、画面があればまず画面でバリデーションされるので今までこの問題が表面化しなかったのは画面のおかげだったんだなぁと思いました。
このバリデーションは1回共通部分として作ってしまえば使いまわせるはずなのでしっかりしたいです。


---


# 参考文献
[Railsガイド](https://railsguides.jp/active_record_validations.html#%E3%83%90%E3%83%AA%E3%83%87%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B)