dry-validation
Rubyにおける契約ベースのバリデーションライブラリ。スキーマとルールを分離した柔軟なバリデーション機能を提供
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において、その真価を発揮するライブラリです。