dry-validation

Rubyにおける契約ベースのバリデーションライブラリ。スキーマとルールを分離した柔軟なバリデーション機能を提供

validationschemacontractsbusiness-logicrubydry-rb

dry-validation

dry-validationは、Rubyにおける強力な契約ベースのバリデーションライブラリです。スキーマとビジネスロジックを分離し、型安全で拡張可能なバリデーション機能を提供します。

特徴

  • 契約ベースのアプローチ: スキーマとルールを分離した設計
  • 型安全性: dry-typesとの統合による型安全なバリデーション
  • 柔軟なエラーハンドリング: 詳細なエラーメッセージとメタデータサポート
  • 多言語対応: i18nによる国際化対応
  • カスタムルール: 独自のバリデーションロジックの定義が可能
  • dry-schemaとの統合: 既存のスキーマの再利用
  • マクロシステム: 再利用可能なバリデーションパターン

インストール

Gemfileに追加:

gem 'dry-validation'

またはgemで直接インストール:

gem install dry-validation

基本的な使い方

基本的な契約定義

require 'dry/validation'

class NewUserContract < Dry::Validation::Contract
  params do
    required(:email).filled(:string)
    required(:age).value(:integer)
  end

  rule(:email) do
    unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
      key.failure('has invalid format')
    end
  end

  rule(:age) do
    key.failure('must be greater than 18') if value <= 18
  end
end

# 使用方法
contract = NewUserContract.new

# 成功例
result = contract.call(email: '[email protected]', age: 25)
puts result.success? # => true
puts result.to_h     # => {:email=>"jane@doe.org", :age=>25}

# 失敗例
result = contract.call(email: '[email protected]', age: 17)
puts result.success?   # => false
puts result.errors.to_h # => {:age=>["must be greater than 18"]}

パラメータ型の強制

class NewUserContract < Dry::Validation::Contract
  # パラメータスキーマ(文字列から型変換を行う)
  params do
    required(:email).value(:string)
    required(:age).value(:integer)
  end
end

# 文字列の年齢も自動的に整数に変換される
result = contract.call('email' => '[email protected]', 'age' => '21')
puts result.to_h # => {:email=>"jane@doe.org", :age=>21}

高度な使い方

複数フィールドにまたがるルール

class EventContract < Dry::Validation::Contract
  params do
    required(:start_date).value(:date)
    required(:end_date).value(:date)
  end

  rule(:end_date, :start_date) do
    key.failure('must be after start date') if values[:end_date] < values[:start_date]
  end
end

contract = EventContract.new
result = contract.call(start_date: Date.today, end_date: Date.today - 1)
puts result.errors.to_h
# => {:end_date=>["must be after start date"]}

ネストした配列のバリデーション

class ContactsContract < Dry::Validation::Contract
  params do
    required(:contacts).value(:array, min_size?: 1).each do
      hash do
        required(:name).filled(:string)
        required(:email).filled(:string)
        required(:phone).filled(:string)
      end
    end
  end

  rule(:contacts).each do |index:|
    key([:contacts, :email, index]).failure('email not valid') unless value[:email].include?('@')
  end
end

contract = ContactsContract.new
result = contract.call(
  contacts: [
    { name: 'Jane', email: '[email protected]', phone: '123' },
    { name: 'John', email: 'invalid-email', phone: '123' }
  ]
)
puts result.errors.to_h
# => {:contacts=>{:email=>{1=>["email not valid"]}}}

外部依存関係の使用

class NewUserContract < Dry::Validation::Contract
  option :user_repo

  params do
    required(:login).filled(:string)
  end

  rule(:login) do
    key.failure('is already taken') if user_repo.exists?(value)
  end
end

# 使用時に依存関係を注入
user_repo = UserRepository.new
contract = NewUserContract.new(user_repo: user_repo)

カスタムマクロの定義

# グローバルマクロ
Dry::Validation.register_macro(:email_format) do
  unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
    key.failure('not a valid email format')
  end
end

# クラス専用マクロ
class ApplicationContract < Dry::Validation::Contract
  register_macro(:min_size) do |macro:|
    min = macro.args[0]
    key.failure("must have at least #{min} elements") unless value.size >= min
  end
end

# マクロの使用
class NewUserContract < ApplicationContract
  params do
    required(:email).filled(:string)
    required(:phone_numbers).value(:array)
  end

  rule(:email).validate(:email_format)
  rule(:phone_numbers).validate(min_size: 1)
end

dry-schemaとの統合

既存スキーマの再利用

require "dry/schema"

# 既存のスキーマを定義
AddressSchema = Dry::Schema.Params do
  required(:country).value(:string)
  required(:zipcode).value(:string)
  required(:street).value(:string)
end

ContactSchema = Dry::Schema.Params do
  required(:email).value(:string) 
  required(:mobile).value(:string)
end

# 複数のスキーマを組み合わせて契約を作成
class NewUserContract < Dry::Validation::Contract
  params(AddressSchema, ContactSchema) do
    required(:name).value(:string)
    required(:age).value(:integer) 
  end

  rule(:email) do
    unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
      key.failure('has invalid format')
    end
  end
end

カスタム型の使用

module Types
  include Dry::Types()

  StrippedString = Types::String.constructor(&:strip)
  Email = Types::String.constrained(
    format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  )
end

class NewUserContract < Dry::Validation::Contract
  params do
    required(:name).value(Types::StrippedString)
    required(:email).value(Types::Email)
    required(:age).value(:integer)
  end
end

エラーハンドリング

詳細なエラーメッセージ

class NewUserContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
  end

  rule(:age) do
    key.failure(text: 'must be greater than 18', code: 'AGE_TOO_LOW') if values[:age] < 18
  end
end

contract = NewUserContract.new
result = contract.call(age: 17)

puts result.errors.to_h
# => {:age=>[{:text=>"must be greater than 18", :code=>"AGE_TOO_LOW"}]}

ベースエラー

class EventContract < Dry::Validation::Contract
  option :today, default: Date.method(:today)

  params do
    required(:start_date).value(:date)
    required(:end_date).value(:date)
  end

  rule do
    if today.saturday? || today.sunday?
      base.failure('creating events is allowed only on weekends')
    end
  end
end

contract = EventContract.new
result = contract.call(start_date: Date.today+1, end_date: Date.today+2)

# ベースエラーは特別なキー(nil)で表現される
puts result.errors.to_h
# => {nil=>["creating events is allowed only on weekends"]}

# またはフィルター方式でアクセス
puts result.errors.filter(:base?).map(&:to_s)
# => ["creating events is allowed only on weekends"]

国際化(i18n)

設定ファイル

# config/locales/en.yml
en:
  dry_validation:
    errors:
      rules:
        age:
          invalid: 'must be greater than 18'
      email_format: "not a valid email format"
    rules:
      name: "First name"

i18n使用例

class ApplicationContract < Dry::Validation::Contract
  config.messages.backend = :i18n
  config.messages.load_paths << 'config/locales'
end

class NewUserContract < ApplicationContract
  params do
    required(:name).filled(:string)
    required(:age).value(:integer)
  end

  rule(:age) do
    key.failure(:invalid) if values[:age] < 18
  end
end

contract = NewUserContract.new
result = contract.call(name: '', age: 17)

# full: trueオプションでフィールド名を含む
puts result.errors(full: true).to_h
# => {:name=>["First name must be filled"], :age=>["First name must be greater than 18"]}

拡張機能

Monads拡張

require 'dry/validation'
require 'dry/monads'

Dry::Validation.load_extensions(:monads)

class CreatePersonService
  include Dry::Monads[:result]

  class Contract < Dry::Validation::Contract
    params do
      required(:first_name).filled(:string)
      required(:last_name).filled(:string)
    end
  end

  def call(input)
    case Contract.new.(input).to_monad
    in Success(first_name:, last_name:)
      Success(create_person(first_name, last_name))
    in Failure(result)
      Failure(result.errors.to_h)
    end
  end

  private

  def create_person(first_name, last_name)
    # Person作成ロジック
    { id: 1, first_name: first_name, last_name: last_name }
  end
end

パターンマッチング

class PersonContract < Dry::Validation::Contract
  params do
    required(:first_name).filled(:string)
    required(:last_name).filled(:string)
  end
end

contract = PersonContract.new

case contract.('first_name' => 'John', 'last_name' => 'Doe')
in { first_name:, last_name: } => result if result.success?
  puts "Hello #{first_name} #{last_name}"
in _ => result
  puts "Invalid input: #{result.errors.to_h}"
end

実用的な例

ユーザー登録フォーム

class UserRegistrationContract < Dry::Validation::Contract
  option :user_repo

  params do
    required(:email).filled(:string)
    required(:password).filled(:string)
    required(:password_confirmation).filled(:string)
    required(:terms_accepted).filled(:bool)
    optional(:newsletter_subscription).filled(:bool)
  end

  rule(:email) do
    unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
      key.failure('has invalid format')
    end
  end

  rule(:email) do
    key.failure('is already taken') if user_repo.email_exists?(value)
  end

  rule(:password) do
    key.failure('must be at least 8 characters long') if value.length < 8
  end

  rule(:password_confirmation, :password) do
    key.failure('passwords must match') if values[:password] != values[:password_confirmation]
  end

  rule(:terms_accepted) do
    key.failure('must be accepted') unless value == true
  end
end

# 使用例
user_repo = UserRepository.new
contract = UserRegistrationContract.new(user_repo: user_repo)

result = contract.call(
  email: '[email protected]',
  password: 'secretpassword',
  password_confirmation: 'secretpassword',
  terms_accepted: true,
  newsletter_subscription: false
)

if result.success?
  user = User.create(result.to_h)
  puts "User created successfully!"
else
  puts "Validation failed: #{result.errors.to_h}"
end

APIリクエストバリデーション

class CreateProductContract < Dry::Validation::Contract
  option :category_repo

  params do
    required(:name).filled(:string)
    required(:price).value(:decimal)
    required(:category_id).value(:integer)
    optional(:description).filled(:string)
    optional(:tags).value(:array).each(:string)
  end

  rule(:price) do
    key.failure('must be greater than 0') if value <= 0
  end

  rule(:category_id) do
    key.failure('category does not exist') unless category_repo.exists?(value)
  end

  rule(:tags) do
    key.failure('maximum 5 tags allowed') if key? && value.length > 5
  end
end

# Rails Controllerでの使用例
class ProductsController < ApplicationController
  def create
    contract = CreateProductContract.new(category_repo: CategoryRepository.new)
    result = contract.call(product_params)

    if result.success?
      product = Product.create(result.to_h)
      render json: product, status: :created
    else
      render json: { errors: result.errors.to_h }, status: :unprocessable_entity
    end
  end

  private

  def product_params
    params.require(:product).permit(:name, :price, :category_id, :description, tags: [])
  end
end

テスト例

require 'dry/validation'

RSpec.describe NewUserContract do
  subject(:contract) { described_class.new }

  describe 'valid input' do
    let(:input) do
      {
        email: '[email protected]',
        age: 25
      }
    end

    it 'passes validation' do
      result = contract.call(input)
      expect(result).to be_success
      expect(result.to_h).to eq(input)
    end
  end

  describe 'invalid email' do
    let(:input) do
      {
        email: 'invalid-email',
        age: 25
      }
    end

    it 'fails validation' do
      result = contract.call(input)
      expect(result).to be_failure
      expect(result.errors.to_h).to eq(email: ['has invalid format'])
    end
  end

  describe 'invalid age' do
    let(:input) do
      {
        email: '[email protected]',
        age: 17
      }
    end

    it 'fails validation' do
      result = contract.call(input)
      expect(result).to be_failure
      expect(result.errors.to_h).to eq(age: ['must be greater than 18'])
    end
  end
end

dry-rbエコシステムとの連携

dry-monadsとの組み合わせ

require 'dry/validation'
require 'dry/monads'

class UserService
  include Dry::Monads[:result, :do]

  class Contract < Dry::Validation::Contract
    params do
      required(:name).filled(:string)
      required(:email).filled(:string)
    end

    rule(:email) do
      unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
        key.failure('has invalid format')
      end
    end
  end

  def call(input)
    values = yield validate(input)
    user = yield create_user(values)
    Success(user)
  end

  private

  def validate(input)
    result = Contract.new.call(input)
    result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
  end

  def create_user(values)
    # User作成ロジック
    Success(User.new(values))
  end
end

まとめ

dry-validationは、Rubyアプリケーションにおいて強力で柔軟なバリデーション機能を提供します。主な利点:

  • 明確な責任分離: スキーマとビジネスルールの分離
  • 型安全性: dry-typesとの統合による堅牢性
  • 拡張性: カスタムルールやマクロによる柔軟性
  • エコシステム: dry-rbライブラリ群との優れた連携
  • 国際化対応: 多言語アプリケーションでの使用が容易

特に、複雑なビジネスロジックを含むWebアプリケーションやAPIにおいて、その真価を発揮するライブラリです。