はじめに
APIだけ開発して、期待していないJSONでリクエストしたら想定外の挙動になったので、その挙動を紹介します。
期待しない型で送ると500エラーになる
次のパラメータを期待したAPIを考えます。
- name
- 文字列型
- 必須
- 1文字以上、10文字以下
- mail
- 文字列型
- 必須
- メールアドレスの正規表現
- age
- 数値型
- 必須
- 0以上の整数
こんな感じのフォームを作ってバリデーションするかと思います。
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
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
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"}
よしよし、それぞれちゃんとバリデーションされていますね。
ちょっとイジワルして別の型で実行してみましょう。
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の型を確かめずに比較をしていたのが原因のようです。
カスタムバリデータでは型チェックを最初にした方がよさそうです。
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
改めてバリデーションを実行してみましょう。
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エラーになりそうです。型をチェックするバリデータを作っても良いかもしれません。
user = User.create!(name: 15, age: '15')
user.name # "15"
user.age # 15
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
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というgemもあるようです。
追記
クエリパラメータの値は文字列で受け取るので、バリデーション時点では文字列型も許容しないとバグになるかもしれません。バリデーションを通った直後に必要な型に変換した方がよさそうです。
# /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
とすると空配列もエラーになってしまいます。
そこで自分はなんとなくで次のように書きました。
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
class SampleForm
include ActiveModel::Model
attr_accessor :id_array
validates :id_array, allow_nil: false, array: true
end
さっそくnilを入れてみましょう
form = SampleForm.new(id_array: nil)
form.valid? # true
アレ?バリデーションが仕事してくれませんでした。。。
Railsガイドを読んでみると、allow_nil: true
にしたときにバリデーションをスキップするのであって、別にfalse
にしたからってnil
でエラーにしてくれるわけではないようです。
デフォルトでは、numericalityのnil値は許容されません。allow_nil: trueオプションでnil値を許可できます。
と同様にカスタムバリデータではnil
ではエラーになるようにしておいて、nil
を許容する場合にallow_nil: true
を指定するのがよさそうです。
パラメータを全然違う型で送ったのにパラメータエラーにならない
配列型を期待しているsome_array
というパラメータがあるとします。このパラメータはnullでも問題なくAPIを実行できるとします。
ここにhoge
という文字列を入れてリクエストすると、以下のようななります。
# 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回共通部分として作ってしまえば使いまわせるはずなのでしっかりしたいです。