Building Custom Rails Attribute Validators
The validation that ships with Rails is useful, albeit generic. It leaves us to construct our own validators as dictated by our domains. Most of our domains share some common data types like emails or phone numbers. Individually they might require SSNs, SINs, credit card numbers, URIs, or any other of a million types of data. The good news is that Rails gives us the tools necessary to build our own validators.
The Setup
We’re going to validate user-added HTML (we can debate the merits of letting users enter HTML at a later time).
In particular we want to know if the HTML has text in it.
Is there something there to see?
For example, if a user inputs <p></p>
we’re going to fail it.
Basically we want the ability to check for presence
like we would with regular text.
If we have an email model with a message body, the validation would look like this:
validates :body, html: {presence: true}
Construction
Let’s start with the basic structure of a validator.
It inherits from ActiveModel::EachValidator
and defines a validate_each
instance method.
The method is provided with a record (an instance of the model), the name of the attribute, and the value being set.
# app/validators/html_validator.rb
class HtmlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
end
end
The class also contains an options
attribute which represents everything that was passed to :html
in the validates
call.
Let’s fill in validate_each
so it checks for the presence of HTML.
class HtmlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if options.key?(:presence) && blank?(value)
record.errors.add(attribute, :blank)
end
end
private
def blank?(value)
# Is the HTML blank?
end
end
The guts of validate_each
are straight forward.
We check to see if the presence
key is used and then check to see if our HTML is blank.
If the HTML is blank then we add an error to the record using the standard Rails blank message.
Now we need to define blank?
.
We’ll use Nokogiri to grab text blocks and see if we find any visible text (it could still be hidden by styling, but let’s keep it simple).
Nokogiri is fast, but even so, let’s make sure there’s something there before we start parsing.
def blank?(value)
value.blank? || Nokogiri::HTML(value).
search('//text()').
map(&:text).
join.
blank?
end
We’ve implemented our check and things are going well. What if we don’t like the default message? Sometimes it’s helpful to pass in a message tailored to the situation.
validates :body, html: {
presence: true,
message: 'must display text before we can send it'
}
Since the error message code is about to get a little more complicated, let’s pull it out of validate_each
.
Now we check the options
hash for a message
key and return the custom message or the default error message.
def validate_each(record, attribute, value)
if options.key?(:presence) && blank?(value)
record.errors.add(attribute, error_message)
end
end
private
def error_message
options.fetch(:messages, :blank)
end
Putting all of that together we get:
# app/validators/html_validator.rb
class HtmlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if options.key?(:presence) && blank?(value)
record.errors.add(attribute, error_message)
end
end
private
def blank?(value)
value.blank? || Nokogiri::HTML(value).
search('//text()').
map(&:text).
join.
blank?
end
def error_message
options.fetch(:messages, :blank)
end
end
Testing
Like all code in our application we want to test our validator.
Let’s go over a few example tests.
We’ll check that <p> </p>
fails and <p>A</p>
passes.
We’ll also check that the default message is right and make sure custom messages work.
describe HtmlValidator do
describe '#validates_each(record, attribute, value)' do
let(:test_model) do
# ...
end
it 'fails when there is no text' do
test_model.html = '<p> </p>'
expect(test_model).to have(1).errors_on(:html)
end
it 'passes when text is in the HTML' do
test_model.html = '<p>A</p>'
expect(test_model).to be_valid
end
it 'returns the default error message' do
test_model.html = ' '
test_model.valid?
expect(
test_model.errors.full_messages
).to eq I18n.t('errors.messages.blank')
end
context 'options' do
context 'contains a custom error message' do
it 'adds the custom message' do
test_model.html = ''
test_model.valid?
expect(
test_model.errors.full_messages
).to eq [custom_message]
end
end
end
end
end
You might have noticed that I left test_model
empty.
We need to build a class that we can use to test our validator.
Actually, we’re going to need two so we can test one with the :message
option set and one without it set.
What if we add new options and we need to test those?
Hard coding classes for each test feels cumbersome.
What we need is a way to easily build classes with different validation options.
To do this we’ll add a method to our spec file that takes an options
hash and returns a custom built class.
The class will quack like a non-persisted ActiveRecord::Base
.
Anonymous classes don’t have names and Rails is going to expect the class to respond to name
.
Fixing this is as easy as adding a class method name
.
def html_validator_class(options)
Class.new do
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
def new_record?
true
end
def persisted?
false
end
def self.name
'Validator'
end
attr_accessor :html
validates :html, html: options
end
end
Odds are good that you’re going to build more than one validator so, you’ll want to extract the generic parts of your class. It’ll also help to expose the parts of the custom class that are important to the tests.
# This class would exist in a helper file.
class ValidationTester
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
def new_record?
true
end
def persisted?
false
end
def self.name
'Validator'
end
end
# app/validators/html_validator.rb
def html_validator_class(options)
Class.new(ValidationTester) do
attr_accessor :html
validates :html, html: options
end
end
Now we go back and fill in test_model
using our new html_validator_class
method.
describe HtmlValidator do
describe '#validates_each(record, attribute, value)' do
let(:options) { {presence: true} }
let(:test_model) { html_validator_class(options).new }
it 'fails when only whitespace is in the HTML' do
test_model.html = '<p> </p>'
expect(test_model).to have(1).errors_on(:html)
end
it 'passes when text is in the HTML' do
test_model.html = '<p>A</p>'
expect(test_model).to be_valid
end
it 'returns the default error message' do
test_model.html = ' '
test_model.valid?
expect(
test_model.errors[:html]
).to eq [I18n.t('errors.messages.blank')]
end
context 'with a custom error message' do
let(:custom_message) { 'this is a custom message' }
before { options.merge!(message: custom_message) }
it 'adds the custom message' do
test_model.html = ''
test_model.valid?
expect(
test_model.errors[:html]
).to eq [custom_message]
end
end
end
end
Validate All the Things
Validators are a critical part of any application. They provide a way to ensure the accuracy and consistency of your data. Using the same validator across models stops your team from littering your application with different ideas about what constitutes a valid phone number. Validators are worth every bit of effort you put into them. Anyone who’s written migrations to fix bad data can attest to that.