ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Input object を使ってリクエストパラメータを検証する

はじめに

Bonjour! 近ごろ Duolingo のフランス語コースにハマっている @ima1zumi です。

さて、最近リクエストパラメータにバリデーションをかけたいことがありました。そのために、ユーザの入力を検証する専用のクラスを作ると便利だったので紹介します。

動作確認環境

  • Ruby 3.0.1
  • Ruby on Rails 6.1.4

実現したいこと

ここでは例として User の一覧を表示する UsersController#index に、表示順を制御する order_keyorder_direction というリクエストパラメータを追加します。

order_keyUser モデルのカラム名を、 order_directionascdesc のみ受けつけます。これらのパラメータにバリデーションをかけて、規定の値のみ受け付けるようにします。

class UserController < ApplicationController
  def index
    # order に渡すクエリパラメータのバリデーションを行いたい
    @users = User.all.order(user_params[:order_key] => user_params[:order_direction])
  end

  private

  def user_params
    params.permit(:order_key, :order_direction)
  end
end

Input object の責務

ここではバリデーションをかけるクラスを Input object と呼ぶことにします。

これは Shopify の Upgrow (現在は非公開) の Input object の考え方を参考にしています。

このオブジェクトの責務は ユーザーの入力を検証しオブジェクト化する ことです。

参考:Upgrow: Railsアプリの保守性を高めるためのShopifyのアプローチ / Upgrow - Speaker Deck

Input object の実装

まず、 ActiveModel::ModelActiveModel::Attributesinclude したクラスを作成します。

app/inputs/user_order_input.rb

class UserOrderInput
  include ActiveModel::Model
  include ActiveModel::Attributes
end

ActiveModel::Attributes

ActiveModel::Attributesinclude すると attribute クラスメソッドを使えるようになります。 attribute で属性を宣言するとアクセサが定義されたり、 initialize でその値を渡すことができるようになります。attribute に属性名と型を渡すと型をキャストします。

class UserOrderInput
  include ActiveModel::Model
  include ActiveModel::Attributes
  # 属性の設定を宣言的に記述する
  attribute :order_key, :string
  attribute :order_direction, :string, default: 'desc'
end

ActiveModel::Attributes のドキュメントはありませんので rails のコードを直接ご覧ください。

github.com

キャストできる型はこちらに書いてあります。

github.com

サンプルコード

attribute で宣言した属性にアクセスすることができます。

user_order_input = UserOrderInput.new(order_key: 'name', order_direction: 'asc')
p user_order_input.order_key
# => name

いろいろな型にキャストしてみました。Boolean などキャストできて便利です。

class SampleInput
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :number, :integer
  attribute :flag, :boolean
  attribute :birthday, :date
end

sample = SampleInput.new(number: '1', flag: 'true', birthday: '1993-02-24')

p sample.number
# => 1
p sample.number.class
# => Integer

p sample.flag
# => true
p sample.flag.class
# => TrueClass

p sample.birthday
# => Wed, 24 Feb 1993
p sample.birthday.class
# => Date

ちなみに、ActiveModel::Attributes -> ActiveModel::Model の順番で include すると initialize メソッドの評価順がおかしくなり動かなくなるので注意してください。

class SampleInput
  include ActiveModel::Attributes
  include ActiveModel::Model

  attribute :name, :string
end
# => #<Concurrent::Map:0x00007f8ab03225e8 entries=0 default_proc=nil>
SampleInput.new(name: 'hoge')
# => /Users/mi/.asdf/installs/ruby/3.0.1/lib/ruby/gems/3.0.0/gems/activemodel-6.1.4/lib/active_model/attributes.rb:124:in `_write_attribute': undefined method `write_from_user' for nil:NilClass (NoMethodError)

ActiveModel::Model

ActiveModel::Modelinclude すると validates クラスメソッドなどのバリデーションメソッドを使えるようになります。

railsguides.jp

class UserOrderInput
  include ActiveModel::Model
  include ActiveModel::Attributes
  attribute :order_key, :string
  attribute :order_direction, :string, default: 'desc'
  # 各属性のバリデーションを記述する
  validates :order_key, inclusion: { in: User.column_names }, presence: true
  validates :order_direction, inclusion: { in: %w(asc desc) }
end

サンプルコード

インスタンスに対して valid? / invalid? でバリデーション結果を確認することが出来ます。

user_order_input = UserOrderInput.new(order_direction: 'asc')
p user_order_input.valid?
# => true
user_order_input = UserOrderInput.new(order_direction: 'hoge')
p user_order_input.valid?
# => false

コントローラから呼び出す

ここでは UsersControllerindex アクションでバリデーションをかけます。

class UserController < ApplicationController
  before_action :valid_params, only: [:index]

  def index
    @users = User.all.order(user_order_input.order_key => user_order_input.order_direction)
  end

  private

  def valid_params
    head :bad_request if user_order_input.invalid?
  end

  def user_order_input
    @user_order_input ||= UserOrderInput.new(user_params)
  end

  def user_params
    params.permit(:order_key, :order_direction)
  end
end

このようにコントローラとバリデーションの責務を分けることができました。

Input object と Form object の違い

Form object はいろいろな定義がありますが、ここでは willnet さんの定義を引用します。

form_withのmodelオプション*1にActive Record以外のオブジェクトを渡すデザインパターンです。form_withのmodelオプションに渡すオブジェクト自体もform objectと呼びます。

(参考:form objectを使ってみよう - メドピア開発者ブログ

Form object と Input object の一番の違いは、 Input object は入力値の検証とインスタンスの作成のみ行うということです。

Form object は名前や性質がフォームと強く結びついています。また Form object を使って save することをスコープに入れる場合があります。

今回紹介した方法では、フォームでなくてもよいです。また、 save は行いません。

ここでは、リクエストパラメータのバリデーションに使える Input object の実装例を紹介しました。

参考