🔍

dry-validationを使った入力値バリデーション方法の紹介

2023/12/21に公開

株式会社COUNTERWORKSでRuby on Railsを採用しているアプリの開発をしているまったんです。
この記事ではデータのスキーマとバリデーションルールを定義するDSLを提供するGemであるdry-validationを使った入力値バリデーションの方法を紹介します。

はじめに

みなさんはフォームなどの入力値に対するバリデーションはどのように行っているでしょうか?
私の観測範囲では以下の2つのパターンがほとんどでした。

  • モデルのバリデーション機能に任せる
  • ActiveModel::Validations をミックスインした、モデルに非依存なクラスに任せる

上記の2パターンだと以下のようなつらさがありました。

  • ユースケースによって変えるようなアプリ(例えばユーザーの種類によってバリデーションルールを変えたり)だとモデルのバリデーション機能に任せるのはつらい
  • モデルにはDBの入力値のバリデーションルールのみを定義し、フォーム等の入力値のバリデーションは ActiveModel::Validations をミックスインしたクラスに任せるようにしても、配列やオブジェクトのバリデーションルールの記述がつらい

上記のようなつらさを軽減するために、弊社では dry-validation を使ってフォーム等の入力値をバリデーションしてから永続化処理をするようにしています。

dry-validationの使い方

dry-validation を使って、以下のようなハッシュのバリデーション方法を紹介します。

{
  name: "鈴木太郎",
  age: 15,
  start_date: "2025-01-01",
  end_date: "2025-10-31",
  is_published: false,
  blood_type: "A",
  categories: [
    "food",
    "animal"
  ],
  guests: [
    {
      name: "田中拓哉",
      height: 155.5
    },
    {
      name: "渡辺大介"
    }
  ]
}

スキーマ、バリデーションルールは以下の通りです。(キー名の前に*が付いていれば必須のキーです)

*name: 文字列/2文字以上
age: 整数/10以上
*start_date: 文字列/ISO8601に準拠した日付フォーマット/未来の日付
*end_date: 文字列/ISO8601に準拠した日付フォーマット/start_dateよりも未来の日付
is_published: 真偽値
*categories: 配列/要素数は2以上
  categoriesの要素: 文字列/特定の値のみ許可(food/animal/fashion/travel)
*guests: 配列
  guestsの要素: オブジェクト
    *name: 文字列/1文字以上
    height: 浮動小数点数/10超過280未満

dry-validation を使って上記のスキーマ、バリデーションルールを表現すると次のようになります。

class Contract < Dry::Validation::Contract
  params do
    required(:name).filled(:str?, min_size?: 2)
    optional(:age).maybe(:int?, gteq?: 10)
    required(:start_date).filled(:date)
    required(:end_date).filled(:date)
    optional(:is_published).filled(:bool?)
    required(:categories).filled(:array?, min_size?: 2).each(:string, included_in?: %w[food animal fashion travel])
    required(:guests).value(:array?).each do
      hash do
        required(:name).value(:str?)
        optional(:height).filled(:float?, gt?: 10, lt?: 280)
      end
    end
  end

  rule(:start_date, :end_date) do
    unless values[:start_date] > Date.current
      key(:start_date).failure('must be after today')
    end

    unless values[:end_date] > Date.current
      key(:end_date).failure('must be after today')
    end

    unless values[:end_date] > values[:start_date]
      key(:end_date).failure('must be after start date')
    end
  end
end

簡単に解説します。

  • params
    バリデーションするデータの型強制などを決めます。paramsは "2023-01-01" のような文字列をDateインスタンスにしてからバリデーションしてくれたります。
    paramsの他にはjsonやschemaなどもあります。

  • required / optional
    キーが必須かどうかを定義します。

  • filled / maybe / value
    値がnullableかどうかなどを決めます。
    文字列型ならfilledはnullも空文字も許さない、配列型ならfilledはnullも空配列も許さないなど、値のデータ型によって若干振る舞いが違います。
    (ActiveSupportのpresent?メソッドのような振る舞いをします。)

  • str? / int? / float? / date / bool? / array? / hash
    データ型を決めます。?がついてると厳しい型チェックをしてくれたりします。
    例えば、整数型はint?かintegerで表現できますが、integerだと 1"1" も許容しますがint?では "1" しか許容されません。

  • min_size? / gteq? / included_in? / gt? / lt?
    値のルールを決めます。文字数や配列の要素数や数値の下限上限などを定義したり、文字列のパターンのセーフリストなどを定義したりできます。

  • rule
    上記の方法では定義できないようなルールを自由に定義できます。

ルールに反していれば以下のようにエラーメッセージを受け取れます。

data = {
  name: "鈴木太郎",
  age: 1,
  start_date: "2025-01-01",
  end_date: "2025-10-31",
  is_published: false,
  categories: [
    "food",
    "animal"
  ],
  guests: [
    {
      name: "田中拓哉",
      height: 155.5,
      
    },
    {
      name: "渡辺大介"
    }
  ]
}

contract = Contract.new

result = contract.call(data)

puts result.errors
=> { age: ["must be greater than or equal to 10"] }

DSLに慣れてしまえば、直感的に少ない記述でスキーマ、バリデーションルールを定義できるのがいいところだと思っています。

使い方に関する詳しい情報は公式ドキュメントをご覧ください。

おわりに

最良の入力値のバリデーションの方法はサービスの特性や規模などによって変わると思っていますが、バリデーション方法で悩んでいる方たちに、この記事を通して新たな選択肢を提供できたらうれしいです。

自分たちがやりたいバリデーション方法が公式ドキュメントに書かれてなくて困っているなんてことがあればX(Twitter)でDM貰えれば助けられるかもしれません。

We are hiring!!

COUNTERWORKS では一緒に働く仲間を絶賛募集中です。
今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!

https://counterworks.co.jp/recruit/?utm_source=zenn&utm_medium=referral&utm_campaign=advent-calendar-2023&utm_content=21

COUNTERWORKS テックブログ

Discussion