dry-validation (1.3) で Form Object を実装する

dry-validation (1.3) で Form Object を実装する

こんにちは、エンジニアの齊藤です。 この記事は Enigmo Advent Calendar 2019 の12日目の記事です。

本日は、バリデーションロジックの開発で Form Object の設計を支える dry-validation について書きたいと思います。

Form Object について

ユーザー向けのウェブアプリケーションの実装で必ずといって発生するのが、インプット値のバリデーション処理です。 ウェブフォームだけでなく API のリクエストパラメーターや CSV ファイルのコンテンツ等そのケースは様々です。

Enigmo で開発しているウェブアプリケーションの多くは Ruby on Rails で実装されています。Rails アプリケーションでは、Model にバリデーションを実装するのが一般的ですが、モデルのアトリビュートを逸脱したこのようなケースではそれぞれ専用のクラスを実装する必要があります。

Form Object 自体は有名な Rails の Fat モデルを改善する 7 Patterns to Refactor Fat ActiveRecord Models という記事のなかで紹介された設計テクニックの一つです。

Before dry-validation

今回紹介する dry-validation 以外の実装では先程の 7 Patterns... にも登場する virtusActiveModel::Validations を使って実装しています。 virtus でアトリビュートを定義して、通常のモデル同様にバリデーションルールを実装します。
実行方法も通常の ActiveModel のインスタンスと差異がないので、バリデーション専用のモデルを実装しているのと同じです。

app/forms/product_form.rb

class ProductForm
  include Virtus.model
  include ActiveModel::Model

  attribute :product_name, String
  attribute :reference_number, Integer
  attribute :images, Array[Hash]

  validates :product_name, presence: true
  validates :reference_number, presence: true
end

実行結果

[11] pry(main)> form = ProductForm.new(product_name: '', reference_number: '1000'); form.valid?; form.errors
=> #<ActiveModel::Errors:0x000055839f9bacd0
 @base=
  #<ProductForm:0x000055839f9bb090
   @errors=#<ActiveModel::Errors:0x000055839f9bacd0 ...>,
   @images=[],
   @product_name="",
   @reference_number=1000,
   @validation_context=nil>,
 @details={:product_name=>[{:error=>:blank}]},
 @messages={:product_name=>["を入力してください。"]}>

dry-validation 1.0 released

virtus 自体は少し前に開発が止まっていたので、作者が新たに開発している dry-validation が気になっていたのですが、全く違う設計のライブラリのため導入までにはいたりませんでした。 バージョン 1.0.0 (執筆時 1.3.1) がリリースされたのをきっかけにこちらに乗り換えてみることにしました。

dry-validation の良いところはスキーマを定義する DSL の記述がわかりやすく、ドメインロジックのバリデーションルールと明確に処理を分離できる点だと思います。

ここからは dry-validation を使った Form Object の実装方法について説明していきます。

Configurations

dry-validation 自体の共通の設定はベースクラスを用意して定義します。 エラーメッセージの出力時のロケールの設定、共通ルールのマクロやカスタムデータタイプをここで定義します。
例えば、ここに定義している StrippedString は、ありがちな前後のスペースを取り除くのと、空白であった場合に nil に強制する文字列のハンドリングのために利用するデータタイプです。

class ApplicationContract < Dry::Validation::Contract
  config.messages.default_locale = :ja
  config.messages.backend = :i18n
  config.messages.load_paths = [
    Rails.root.join('config/locales/ja.yml'),
    Rails.root.join('config/locales/en.yml')
  ]

  module Types
    include Dry::Types()

    StrippedString = Types::String.constructor { |str| str.strip.presence }
  end
end

Schemas

Schemas は dry-validation の重要な機能でデータをプリプロセスしてバリデーションするための DSL です。 例として以下のようなパラメーターを Schemas を定義してバリデーションします。
params は HTTP パラメーター向けのスキーマ定義のメソッドです。 スキーマ定義の DSL はほぼ記述そのままなので理解しやすいのではないでしょうか? バリデーションの実行結果からクレンジングされた入力値とエラーがそれぞれ .values.errors で取得できます。

{
  "product_name": " Rustic Paper Gloves",
  "reference_number": "59142",
  "images": [
    { "url": "http://ruelarmstrong.com/howard", "caption": "" },
    { "url": "boyer.name/rhett_wunsch", "caption": "" }
  ],
  "description": "                 "
}

app/contracts/product_contract.rb

class ProductContract < ApplicationContract
  params do
    required(:product_name).filled(Types::StrippedString, max_size?: 200)
    required(:reference_number).filled(:integer)
    optional(:images).value(:array, min_size?: 1).array(:hash) do
      required(:url).filled(:string, format?: URI::DEFAULT_PARSER.make_regexp)
      optional(:caption).maybe(Types::StrippedString, max_size?: 500)
    end
    optional(:description).maybe(Types::StrippedString, max_size?: 1000)
  end
end

実行結果

[7] pry(main)> result = ProductContract.new.call(product_name: "  Rustic Paper Gloves        ", images: [{ url: 'http://ruelarmstrong.com/howard', caption: '' }, { url: 'boyer.name/rhett_wunsch', caption: '' }], reference_number: '59142', description: '          ')

=> #<Dry::Validation::Result{:product_name=>"Rustic Paper Gloves", :reference_number=>59142, :images=>[{:url=>"http://ruelarmstrong.com/howard"}, {:url=>"boyer.name/rhett_wunsch"}], :description=>nil} errors={:images=>{1=>{:url=>["は不 正な値です。"]}}}>
[8] pry(main)> ap result.values.to_h
{
        :product_name => "Rustic Paper Gloves",
    :reference_number => 59142,
              :images => [
        [0] {
                :url => "http://ruelarmstrong.com/howard",
            :caption => nil
        },
        [1] {
                :url => "boyer.name/rhett_wunsch",
            :caption => nil
        }
    ],
         :description => nil
}
=> nil
[9] pry(main)> ap result.errors
{
    :images => {
        1 => {
            :url => [
                [0] "は不正な値です。"
            ]
        }
    }
}
=> nil

Rules

スキーマ定義をクリアしたデータをさらに Rules を使ってドメインロジックのバリデーションを実行できます。ここで扱うデータはスキーマ定義をクリアしているのでロジック自体の実装に集中することが可能です。

ここでは例として単純な reference_number が存在するかをデータベースに問い合わせるドメインロジックと、各 imagesの URL の妥当性を判定するルールを追加しました。

class ProductContract < ApplicationContract
  ...

  rule(:reference_number) do
    next if Product.exists?(reference_number: value)

    key.failure(:invalid)
  end

  rule(:images).each do
    next if ImageChecker.call(value[:url])

    key(key_name << :url).failure(:invalid)
  end
end

実行結果

[88] pry(main)> result = ProductContract.new.call(product_name: "  Rustic Paper Gloves        ", images: [{ url: 'http://ruelarmstrong.com/howard', caption: '' }, { url: 'http://boyer.name/rhett_wunsch', caption: '' }], reference_number: '59143', description: '          '); ap result.errors
  Product Exists? (1.2ms)  SELECT 1 AS one FROM "products" WHERE "products"."reference_number" = $1 LIMIT $2  [["reference_number", "59143"], ["LIMIT", 1]]
{
    :reference_number => [
        [0] "は不正な値です。"
    ],
              :images => {
        0 => {
            :url => [
                [0] "は不正な値です。"
            ]
        },
        1 => {
            :url => [
                [0] "は不正な値です。"
            ]
        }
    }
}
=> nil

Macros

例えば、登録フォームとログインフォーム、ログイン API で ID であるメールアドレスのフォーマットを判定するロジックを共通化したい場合 Macros が利用できます。 マクロのブロックにはパラメーターを使うことも可能です。

app/contracts/application_contract.rb

class ApplicationContract < Dry::Validation::Contract
  ...

  register_macro(:email_format) do
    unless URI::MailTo::EMAIL_REGEXP.match?(value)
      key.failure(:invalid_email_format)
    end
  end
end

app/contracts/login_contract.rb

class LoginContract < ApplicationContract
  params do
    required(:email).filled(Types::StrippedString)
    required(:password).filled(Types::StrippedString)
  end

  rule(:email).validate(:email_format)
end

app/contracts/registration_contract.rb

class RegistrationContract < ApplicationContract
  params do
    required(:email).filled(Types::StrippedString)
    required(:first_name).filled(Types::StrippedString)
    required(:last_name).filled(Types::StrippedString)
  end

  rule(:email).validate(:email_format)
end

実行結果

[15] pry(main)> result = RegistrationContract.new.call(email: 'foo'); ap result.errors
{
    :first_name => [
        [0] "を入力してください。"
    ],
     :last_name => [
        [0] "を入力してください。"
    ],
         :email => [
        [0] "は不正なメールアドレスです。"
    ]
}
=> nil
[16] pry(main)> result = LoginContract.new.call(email: 'bar', passsword: nil); ap result.errors
{
    :password => [
        [0] "を入力してください。"
    ],
       :email => [
        [0] "は不正なメールアドレスです。"
    ]
}
=> nil

Rspec

ユニットテストでは、シンプルにバリデーションメッセージの期待値をテストしています。

spec/contracts/product_contract_spec.rb

describe ProductContract do
  subject { result.errors.to_h }

  let(:contract) { described_class.new }
  let(:result) { contract.call(params) }

  describe 'params' do
    describe 'product_name' do
      subject { super()[:product_name] }

      describe '.filled?' do
        context 'filled with valid name' do
          let(:params) { { product_name: Faker::Commerce.product_name } }
          it { is_expected.to eq(nil) }
        end

        context 'not filled' do
          context 'without key' do
            let(:params) { {} }
            it { is_expected.to eq(['を入力してください。']) }
          end

          context 'nil' do
            let(:params) { { product_name: nil } }
            it { is_expected.to eq(['を入力してください。']) }
          end
        end
      end
    end
  end
end

実行結果

ProductContract
  params
    product_name
      .filled?
        filled with valid name
          is expected to eq nil
        not filled
          without key
            is expected to eq ["を入力してください。"]
          nil
            is expected to eq ["を入力してください。"]

Finished in 0.57222 seconds (files took 9.7 seconds to load)
3 examples, 0 failures

Conclusion

最近の開発で以前 virtus ベースで実装したかなり大規模な入力値のバリデーションを dry-validation を使って実装したのですが、コントラクト自体のテストも実装しやすく柔軟に構成できるため、今後も利用していきたいと思います。 ここでは紹介しきれない機能がまだまだありますし dry-rb 自体興味深いプロジェクトなので、ぜひドキュメンテーションソースを参照していただければと思います。


参考

株式会社エニグモ 正社員の求人一覧

hrmos.co