Ruby on Rails で Web API のパラメータをバリデーションする
MMMの前田です。
早いもので、MMMに入社してから1年が経過しました。
振り返ってみると、この1年はほぼRuby on RailsでWeb APIを作っていました。
常にRailsに触れることが出来、非常に楽しい一年になりました。
本日はRuby on Rails の Web APIで、クライアントからのパラメータをどのようにチェックしてバリデーションをしているかを纏めてみました。
ruby version 2.2.2
Ruby on Rails version 4.2.2
何故WebAPIのリクエストパラメータのバリデーションが必要なのか
例えばバリデーション無しでパラメータを受け取った場合、以下のコードでパラメータによって期待しない結果になったりします。
Book.where(id: params[:book_id])
上記のコードはActiveRecordの基本的な検索パターンですが、params[:book_id]
が文字列でも数値でも配列でも検索します。
型を厳密にチェックしなくても動いてしまう、というところがRubyのいいところでもあり、悪いところですよね。
型のErrorにならないということは、開発者が厳密にチェックしなければならない、ということになります。
上記のコードでは、サーバー側では配列で受け取り、レスポンス一覧を返す、という処理をしたかったとしても、クライアントが配列でポストしない時があるかもしれません。
しかし、クライアントは少なくとも、1つはレスポンスを取得出来るので、エラーではないと勘違いし、アプリケーションがリリースされてしまうかもしれません。
こういったことを未然に防ぐために、型チェックなどのバリデーション処理が必要ということですね。
ドキュメントなどに配列でポストして下さいと書いているのでクライアントの責任ですよ、という見方もあるかもしれませんが、そもそもAPIとして、期待しないパラメータで受け付けが出来てしまう、ということがおかしいのだと思います。
WebAPIは、様々な開発者が使う可能性がありますので、開発者によってミスが起こったりするかもしれません。
結論としては、やはりサーバー側でドキュメント記載通りにパラメータを厳密にチェックして、不正な時はクライアントに教えてあげる、という処理をすることが一番良いのだと思います。
どのように実装するか
前置きはさておき、実際のAPIのバリデーションをどのように実装するのがRuby on Railsとして良い方法なのか?ということを、社内のサーバーチームで議論してきました。
現在使っている方法は、APIのリクエストパラメータ専用のバリデーションクラスを作り、チェックする、という実装をしています。
・リクエストパラメータが多い時やバリデーションロジックが複雑な時は、保存や検索をするクラスとバリデーションのみをするクラスを分割する
・リクエストパラメータが少ない時は保存や検索するクラスとバリデーションするクラスを一緒にする
上記2つのパターンに分けて実装をしています。
リクエストパラメータが少ない時
例えば本を保存する、というAPIを作ってみます。
保存 + バリデーションクラス
# Book
class BookForm
include Virtus.model
include ActiveModel::Model
NATURAL_NUMBER = { only_integer: true, greater_than_or_equal_to: 0 }
attribute :price, Integer
attribute :author, String
attribute :publisher, String
attribute :store, Array
validates :book_id, presence: true, numericality: NATURAL_NUMBER
validates :author, presence: true
validates :publisher, presence: true
validates :store_id, array_with_integer: true, if: 'store.present?'
def save!
book = Book.new(price: price, author: author, publisher: publisher)
book.stores = Store.where(id: sotore_id)
book.save!
end
end
Virtus.modelで属性にマッピング、ActiveModel::Modelのバリデーション機能を使う為にそれぞれをincludeしています。
こちらのサイトを参考に実装しています。
肥大化したActiveRecordモデルをリファクタリングする7つの方法
コントローラーの実装
# Api
module Api
# version 1
module V1
# Book
class BookController < ActionController::Base
def create
book_form = BookForm.new(book_params)
render_400(book_form) if book_form.invalid?
book_form.save!
render_200
end
def book_params
params.permit(:price, :author, :publisher, :sotore_id)
end
def render_200
render json: { status: 200, message: 'success!' }
end
def render_400(book_form)
json = { status: 400,
message: 'invalid params',
error_params: errors(book_form) }
render json: json
end
def errors(book_form)
book_form.errors.messages.keys.map(&:to_s)
end
end
end
end
BookFormクラスに対してActiveModelのinvalid?**メソッドを使用して、バリデーションをしています。
パラメータがエラーの時は、どのパラメータがinvalid**なのかも返してあげるようにしています。
リクエストパラメータが多い時や、バリデーションロジックが複雑な時
リクエストパラメータが多い時はやロジックが複雑な時は、更にバリデーションクラスを別で作成します。
# BooksParamsValidator
class BooksParamsValidator
include ActiveModel::Model
NATURAL_NUMBER = { only_integer: true, greater_than_or_equal_to: 0 }
attr_accessor :book_id,
:author,
:publisher,
:store,
:publisher_tel,
:publisher_email
validates :book_id, presence: true, numericality: NATURAL_NUMBER
validates :author, presence: true
validates :publisher, presence: true
validates :store, array_with_integer: true, allow_blank: true
# publisher_telとpublisher_emailはどちらかが必須で、
# 同時に指定出来ない、というロジック
validates :publisher_tel, presence: true, if: 'publisher_email.nil?'
validates :publisher_email, presence: true, if: 'publisher_tel.nil?'
validate :prohibit_2_publisher_attributes
def prohibit_2_publisher_attributes
errors_add(:publisher) if publisher_tel.present? && publisher_email.present?
end
def errors_add(key)
errors.add(key, key.to_s.delete('_id') + ' have 2 attributes')
end
class << self
def init_with_params(params)
new(params.permit(:book_id,
:author,
:publisher,
:publisher_tel,
:publisher_email,
store: []))
end
end
end
上記のようにバリデーションロジックが複雑になってくると、バリデーションクラスを分けたほうが見通しが良くなって良いと思います。
init_with_params(params)
メソッドでコントローラーのActionController::Parameters
をそのまま受け取ってpermit
するようにし、コントローラーの負担を軽くしています。
コントローラーでは下記のように書きます。
book_validator = BookValidator.init_with_params(params)
render_400(book_validator) if book_validator.inivalid?
また、バリデーションクラスでvalidates :store, array_with_integer
のように書いている配列で、かつ中身が数値かを確認するarray_with_integerバリデーションメソッドは下記のように実装しています。
class ArrayWithIntegerValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return errors_add(record, attribute) if value.nil?
return errors_add(record, attribute) unless value.instance_of?(Array)
return errors_add(record, attribute) if value.empty?
value.each do |v|
return errors_add(record, attribute) unless v.is_a?(Integer)
end
end
def errors_add(record, attribute)
record.errors[attribute] << (options[:message] || 'not array with integer')
end
end
これで結構厳密にチェック機能を持たせ、バリデーションロジックを分割することにより、ソースコードの見通しを良くすることができたのではないかと思います。
テストについて
バリデーションクラスでテストするか、end-to-end testでテストするかどちらが良いか。
DBに保存されるまでには以下のバリデーションが通ります。
・リクエストパラメータのバリデーション
・ActiveRecordのバリデーション
・DBのバリデーション
本来、上記の各バリデーションを同一にするべきかもしれませんが、完全に同一にするのは難しい場合があります。
バリデーションクラスのテストのみだと不十分な場合がありますので、必ずend-to-end testでチェックし、重複になるバリデーションクラスのテストは省略することにしています。
以上、弊社で実装しているRuby on Rails での WebAPIのバリデーションについて纏めてみました。
実装で悩んでいる方の参考になれば幸いです。
Ruby on Railsを活用したWebサービスや業務システム開発をご検討の企業様は、是非MMMにご相談下さいませ!