Ruby on Rails で Web API のパラメータをバリデーションする

MMMの前田です。
早いもので、MMMに入社してから1年が経過しました。
振り返ってみると、この1年はほぼRuby on RailsWeb APIを作っていました。
常にRailsに触れることが出来、非常に楽しい一年になりました。

本日はRuby on RailsWeb APIで、クライアントからのパラメータをどのようにチェックしてバリデーションをしているかを纏めてみました。

ruby version 2.2.2
Ruby on Rails version 4.2.2

何故WebAPIのリクエストパラメータのバリデーションが必要なのか

例えばバリデーション無しでパラメータを受け取った場合、以下のコードでパラメータによって期待しない結果になったりします。

1
Book.where(id: params[:book_id])

上記のコードはActiveRecordの基本的な検索パターンですが、params[:book_id]が文字列でも数値でも配列でも検索します。
型を厳密にチェックしなくても動いてしまう、というところがRubyのいいところでもあり、悪いところですよね。
型のErrorにならないということは、開発者が厳密にチェックしなければならない、ということになります。

上記のコードでは、サーバー側では配列で受け取り、レスポンス一覧を返す、という処理をしたかったとしても、クライアントが配列でポストしない時があるかもしれません。
しかし、クライアントは少なくとも、1つはレスポンスを取得出来るので、エラーではないと勘違いし、アプリケーションがリリースされてしまうかもしれません。

こういったことを未然に防ぐために、型チェックなどのバリデーション処理が必要ということですね。
ドキュメントなどに配列でポストして下さいと書いているのでクライアントの責任ですよ、という見方もあるかもしれませんが、そもそもAPIとして、期待しないパラメータで受け付けが出来てしまう、ということがおかしいのだと思います。
WebAPIは、様々な開発者が使う可能性がありますので、開発者によってミスが起こったりするかもしれません。
結論としては、やはりサーバー側でドキュメント記載通りにパラメータを厳密にチェックして、不正な時はクライアントに教えてあげる、という処理をすることが一番良いのだと思います。

どのように実装するか

前置きはさておき、実際のAPIのバリデーションをどのように実装するのがRuby on Railsとして良い方法なのか?ということを、社内のサーバーチームで議論してきました。

現在使っている方法は、APIのリクエストパラメータ専用のバリデーションクラスを作り、チェックする、という実装をしています。

・リクエストパラメータが多い時やバリデーションロジックが複雑な時は、保存や検索をするクラスとバリデーションのみをするクラスを分割する
・リクエストパラメータが少ない時は保存や検索するクラスとバリデーションするクラスを一緒にする

上記2つのパターンに分けて実装をしています。

リクエストパラメータが少ない時

例えば本を保存する、というAPIを作ってみます。

保存 + バリデーションクラス

1
# 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つの方法

コントローラーの実装

1
# 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クラスに対してActiveModelinvalid?メソッドを使用して、バリデーションをしています。
パラメータがエラーの時は、どのパラメータがinvalidなのかも返してあげるようにしています。

リクエストパラメータが多い時や、バリデーションロジックが複雑な時

リクエストパラメータが多い時はやロジックが複雑な時は、更にバリデーションクラスを別で作成します。

1
# 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するようにし、コントローラーの負担を軽くしています。
コントローラーでは下記のように書きます。

1
book_validator = BookValidator.init_with_params(params)
render_400(book_validator) if book_validator.inivalid?

また、バリデーションクラスでvalidates :store, array_with_integerのように書いている配列で、かつ中身が数値かを確認するarray_with_integerバリデーションメソッドは下記のように実装しています。

1
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にご相談下さいませ!

このエントリーをはてなブックマークに追加