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... にも登場する virtus と ActiveModel::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
=>
@base=
@errors=
@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: ' ')
=>
[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
ユニットテストでは、シンプルにバリデーションメッセージの期待値をテストしています。
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