Issue
I have a Dry::Struct and a custom Constructor which checks some constraints before creating an instance of that struct. For my internal code, I want to make sure I hit errors during test if I try and use that constructor with invalid arguments. This works as expected.
I would like to use that same constructor for preprocessing for my Validation.Params. However, I was surprised to find that if my custom constructor raises a constraint error, the validator succeeds and passes the value through in its raw form.
One way to ensure validation fails is to use the :type?
predicate on the resulting value. However, the error message for this is not very helpful as it does not include the actual constraint that failed.
Any suggestions on how to make my validator fail with a more useful error? I would like to avoid duplicating the constraint definition/message, and I would also like to avoid having to post-process the form value.
Example Code
In the example code, I want to make sure a SmallHorse can only be created with a name that ends in “y”. I would like my form to fail with a message that indicates this constraint.
##
# Type & Schema definition
require 'dry-types'
require 'dry-struct'
require 'dry-validation'
require 'dry-logic'
module Types
include Dry::Types.module
class Horse < Dry::Struct
attribute :name, Types::Strict::String
attribute :size, Types::Strict::String.enum('small', 'medium', 'large')
end
SmallHorse = Constructor(Horse) do |name|
ends_in_y = Types::Strict::String.constrained(format: /y$/)
constrained_name = ends_in_y[name]
Horse.new(name: constrained_name, size: 'small')
end
end
SmallHorseForm = Dry::Validation.Params do
configure { config.type_specs = true }
# Option 1: Does not raise error when constructor fails
# required(:horse, Types::SmallHorse)
# Option 2: Resulting error message: "must be Types::Horse"
required(:horse, Types::SmallHorse).value(type?: Types::Horse)
end
##
# Tests I want to pass
require 'rspec'
RSpec.describe Types::SmallHorse do
context 'when the horse param ends in "y"' do
it 'creates a small horse' do
horse = Types::SmallHorse['Dippy']
expect(horse).to be_a_kind_of(Types::Horse)
expect(horse.name).to eq 'Dippy'
expect(horse.size).to eq 'small'
end
end
context 'when the horse param ends in "o"' do
it 'raises a constraint error' do
expect { Types::SmallHorse['Lungo'] }
.to raise_error(Dry::Types::ConstraintError)
end
end
context 'when the horse param is a number' do
it 'raises a constraint error' do
expect { Types::SmallHorse[99] }
.to raise_error(Dry::Types::ConstraintError)
end
end
end
RSpec.describe 'SmallHorseForm' do
context 'when the horse param ends in "y"' do
it 'succeeds' do
result = SmallHorseForm.call(horse: 'Dippy')
expect(result).to be_success
expect(result[:horse]).to be_a_kind_of(Types::Horse)
expect(result[:horse].name).to eq 'Dippy'
end
end
context 'when the horse param ends in "o"' do
it 'fails' do
result = SmallHorseForm.call(horse: 'Lungo')
expect(result).to be_failure
expect(result.messages).to include(:horse)
expect(result.messages[:horse].first).to match /name must end in "y"/
end
end
indent preformatted text by 4 spaces
context 'when the horse param is a number' do
it 'fails' do
result = SmallHorseForm.call(horse: 99)
expect(result).to be_failure
expect(result.messages).to include(:horse)
expect(result.messages[:horse].first).to match /name must end in "y"/
end
end
end