Validations mixin for Ruby objects
- Home page: http://hanamirb.org
- Community: http://hanamirb.org/community
- Guides: https://guides.hanamirb.org
- Mailing List: http://hanamirb.org/mailing-list
- API Doc: http://rdoc.info/gems/hanami-validations
- Bugs/Issues: https://github.com/hanami/validations/issues
- Support: http://stackoverflow.com/questions/tagged/hanami
- Chat: http://chat.hanamirb.org
Hanami::Validations supports Ruby (MRI) 2.3+ and JRuby 9.1.5.0+.
Add this line to your application's Gemfile:
gem 'hanami-validations'
And then execute:
$ bundle
Or install it yourself as:
$ gem install hanami-validations
Hanami::Validations
is a mixin that, once included by an object, adds lightweight set of validations to it.
It works with input hashes and lets us to define a set of validation rules for each key/value pair. These rules are wrapped by lambdas (or special DSL) that check the input for a specific key to determine if it's valid or not. To do that, we translate business requirements into predicates that are chained together with Ruby faux boolean logic operators (eg. &
or |
).
Think of a signup form. We need to ensure data integrity for the name
field with the following rules. It is required, it has to be: filled and a string and its size must be greater than 3 chars, but lesser than 64. Here’s the code, read it aloud and notice how it perfectly expresses our needs for name
.
class Signup
include Hanami::Validations
validations do
required(:name) { filled? & str? & size?(3..64) }
end
end
result = Signup.new(name: "Luca").validate
result.success? # => true
There is more that Hanami::Validations
can do: type safety, composition, complex data structures, built-in and custom predicates.
But before to dive into advanced topics, we need to understand the basics of boolean logic.
When we check data, we expect only two outcomes: an input can be valid or not. No grey areas, nor fuzzy results. It’s white or black, 1 or 0, true
or false
and boolean logic is the perfect tool to express these two states. Indeed, a Ruby boolean expression can only return true
or false
.
To better recognise the pattern, let’s get back to the example above. This time we will map the natural language rules with programming language rules.
A name must be filled and be a string and its size must be included between 3 and 64.
👇 👇 👇 👇 👇 👇 👇 👇
required(:name) { filled? & str? & size? (3 .. 64) }
Now, I hope you’ll never format code like that, but in this case, that formatting serves well our purpose to show how Ruby’s simplicity helps to define complex rules with no effort.
From a high level perspective, we can tell that input data for name
is valid only if all the requirements are satisfied. That’s because we used &
.
We support four logic operators:
&
(aliased asand
) for conjunction|
(aliased asor
) for disjunction>
(aliased asthen
) for implication^
(aliased asxor
) for exclusive disjunction
Please notice that we used &
over Ruby's &&
keyword.
That's because the context of execution of these validations isn't a plain lambda, but something richer.
For real world projects, we want to support common scenarios without the need of reinventing the wheel ourselves. Scenarios like password confirmation, size check are already prepackaged with Hanami::Validations
.
⚠ For this reason, we don't allow any arbitrary Ruby code to be executed, but only well defined predicates. ⚠
To meet our needs, Hanami::Validations
has an extensive collection of built-in predicates. A predicate is the expression of a business requirement (e.g. size greater than). The chain of several predicates determines if input data is valid or not.
We already met filled?
and size?
, now let’s introduce the rest of them. They capture common use cases with web forms.
It checks if the the given value is an array, and iterates through its elements to perform checks on each of them.
required(:codes) { array? { each { int? } } }
This example checks if codes
is an array and if all the elements are integers, whereas the following example checks there are a minimum of 2 elements and all elements are strings.
required(:codes) { array? { min_size?(2) & each { str? } } }
It checks if the given value is empty or not. It is designed to works with strings and collections (array and hash).
required(:tags) { empty? }
This predicate tests if the input is equal to a given value.
required(:magic_number) { eql?(23) }
Ruby types are respected: 23
(an integer) is only equal to 23
, and not to "23"
(a string). See Type Safety section.
It checks if the input is not included by a given collection. This collection can be an array, a set, a range or any object that responds to #include?
.
required(:genre) { excluded_from?(%w(pop dance)) }
This is a predicate that works with a regular expression to match it against data input.
require 'uri'
HTTP_FORMAT = URI.regexp(%w(http https))
required(:url) { format?(HTTP_FORMAT) }
This predicate works with numbers to check if input is greater than a given threshold.
required(:age) { gt?(18) }
This is an open boundary variation of gt?
. It checks if an input is greater than or equal of a given number.
required(:age) { gteq?(19) }
This predicate is the opposite of #exclude?
: it verifies if the input is included in the given collection.
required(:genre) { included_in?(%w(rock folk)) }
This is the complement of #gt?
: it checks for less than numbers.
required(:age) { lt?(7) }
Similarly to #gteq?
, this is the open bounded version of #lt?
: an input is valid if it’s less than or equal to a number.
required(:age) { lteq?(6) }
It’s a predicate that ensures data input is filled, that means not nil
or blank (""
) or empty (in case we expect a collection).
required(:name) { filled? } # string
required(:languages) { filled? } # collection
This verifies that the size of the given input is at least of the specified value.
required(:password) { min_size?(12) }
This verifies that the size of the given input is at max of the specified value.
required(:name) { max_size?(128) }
This verifies if the given input is nil
. Blank strings (""
) won’t pass this test and return false
.
required(:location) { none? }
It checks if the size of input data is: a) exactly the same of a given quantity or b) it falls into a range.
required(:two_factor_auth_code) { size?(6) } # exact
required(:password) { size?(8..32) } # range
The check works with strings and collections.
required(:answers) { size?(2) } # only 2 answers are allowed
This predicate works with objects that respond to #size
. Until now we have seen strings and arrays being analysed by this validation, but there is another interesting usage: files.
When a user uploads a file, the web server sets an instance of Tempfile
, which responds to #size
. That means we can validate the weight in bytes of file uploads.
MEGABYTE = 1024 ** 2
required(:avatar) { size?(1..(5 * MEGABYTE)) }
We have seen that built-in predicates as an expressive tool to get our job done with common use cases.
But what if our case is not common? We can define our own custom predicates.
If we are facing a really unique validation that don't need to be reused across our code, we can opt for an inline custom predicate:
require 'hanami/validations'
class Signup
include Hanami::Validations
predicate :url?, message: 'must be an URL' do |current|
# ...
end
validations do
required(:website) { url? }
end
end
If our goal is to share common used custom predicates, we can include them in a module to use in all our validators:
require 'hanami/validations'
module MyPredicates
include Hanami::Validations::Predicates
self.messages_path = 'config/errors.yml'
predicate(:email?) do |current|
current.match(/.../)
end
end
We have defined a module MyPredicates
with the purpose to share its custom predicates with all the validators that need them.
require 'hanami/validations'
require_relative 'my_predicates'
class Signup
include Hanami::Validations
predicates MyPredicates
validations do
required(:email) { email? }
end
end
HTML forms can have required or optional fields. We can express this concept with two methods in our validations: required
(which we already met in previous examples), and optional
.
require 'hanami/validations'
class Signup
include Hanami::Validations
validations do
required(:email) { ... }
optional(:referral) { ... }
end
end
At this point, we need to explicitly tell something really important about built-in predicates. Each of them have expectations about the methods that an input is able to respond to.
Why this is so important? Because if we try to invoke a method on the input we’ll get a NoMethodError
if the input doesn’t respond to it. Which isn’t nice, right?
Before to use a predicate, we want to ensure that the input is an instance of the expected type. Let’s introduce another new predicate for our need: #type?
.
required(:age) { type?(Integer) & gteq?(18) }
It takes the input and tries to coerce it. If it fails, the execution stops. If it succeed, the subsequent predicates can trust #type?
and be sure that the input is an integer.
We suggest to use #type?
at the beginning of the validations block. This type safety policy is crucial to prevent runtime errors.
Hanami::Validations
supports the most common Ruby types:
Array
(aliased asarray?
)BigDecimal
(aliased asdecimal?
)Boolean
(aliased asbool?
)Date
(aliased asdate?
)DateTime
(aliased asdate_time?
)Float
(aliased asfloat?
)Hash
(aliased ashash?
)Integer
(aliased asint?
)String
(aliased asstr?
)Time
(aliased astime?
)
For each supported type, there a convenient predicate that acts as an alias. For instance, the two lines of code below are equivalent.
required(:age) { type?(Integer) }
required(:age) { int? }
Rule composition with blocks is powerful, but it can become verbose.
To reduce verbosity, Hanami::Validations
offers convenient macros that are internally expanded (aka interpreted) to an equivalent block expression
To use when we expect a value to be filled:
# expands to
# required(:age) { filled? }
required(:age).filled
# expands to
# required(:age) { filled? & type?(Integer) }
required(:age).filled(:int?)
# expands to
# required(:age) { filled? & type?(Integer) & gt?(18) }
required(:age).filled(:int?, gt?: 18)
In the examples above age
is always required as value.
To use when a value can be nil:
# expands to
# required(:age) { none? | int? }
required(:age).maybe(:int?)
In the example above age
can be nil
, but if we send the value, it must be an integer.
To use when we want to apply the same validation rules to all the elements of an array:
# expands to
# required(:tags) { array? { each { str? } } }
required(:tags).each(:str?)
In the example above tags
must be an array of strings.
This is designed to check if pairs of web form fields have the same value. One wildly popular example is password confirmation.
required(:password).filled.confirmation
It is valid if the input has password
and password_confirmation
keys with the same exact value.
⚠ CONVENTION: For a given key password
, the confirmation predicate expects another key password_confirmation
. Easy to tell, it’s the concatenation of the original key with the _confirmation
suffix. Their values must be equal. ⚠
An important precondition to check before to implement a validator is about the expected input.
When we use validators for already preprocessed data it's safe to use basic validations from Hanami::Validations
mixin.
If the data is coming directly from user input via a HTTP form, it's advisable to use Hanami::Validations::Form
instead.
The two mixins have the same API, but the latter is able to do low level input preprocessing specific for forms. For instance, blank inputs are casted to nil
in order to avoid blank strings in the database.
Predicates and macros are tools to code validations that concern a single key like first_name
or email
.
If the outcome of a validation depends on two or more attributes we can use rules.
Here's a practical example: a job board.
We want to validate the form of the job creation with some mandatory fields: type
(full time, part-time, contract), title
(eg. Developer), description
, company
(just the name) and a website
(which is optional).
An user must specify the location: on-site or remote. If it's on site, they must specify the location
, otherwise they have to tick the checkbox for remote
.
Here's the code:
class CreateJob
include Hanami::Validations::Form
validations do
required(:type).filled(:int?, included_in?: [1, 2, 3])
optional(:location).maybe(:str?)
optional(:remote).maybe(:bool?)
required(:title).filled(:str?)
required(:description).filled(:str?)
required(:company).filled(:str?)
optional(:website).filled(:str?, format?: URI.regexp(%w(http https)))
rule(location_presence: [:location, :remote]) do |location, remote|
(remote.none? | remote.false?).then(location.filled?) &
remote.true?.then(location.none?)
end
end
end
We specify a rule with rule
method, which takes an arbitrary name and an array of preconditions.
Only if :location
and :remote
are valid according to their validations described above, the rule
block is evaluated.
The block yields the same exact keys that we put in the precondintions.
So for [:location, :remote]
it will yield the corresponding values, bound to the location
and remote
variables.
We can use these variables to define the rule. We covered a few cases:
- If
remote
is missing or false, thenlocation
must be filled - If
remote
is true, thenlocation
must be omitted
While we’re building complex web forms, we may find comfortable to organise data in a hierarchy of cohesive input fields. For instance, all the fields related to a customer, may have the customer
prefix. To reflect this arrangement on the server side, we can group keys.
validations do
required(:customer).schema do
required(:email) { … }
required(:name) { … }
# other validations …
end
end
Groups can be deeply nested, without any limitation.
validations do
required(:customer).schema do
# other validations …
required(:address).schema do
required(:street) { … }
# other address validations …
end
end
end
Until now, we have seen only small snippets to show specific features. That really close view prevents us to see the big picture of complex real world projects.
As the code base grows, it’s a good practice to DRY validation rules.
class AddressValidator
include Hanami::Validations
validations do
required(:street) { … }
end
end
This validator can be reused by other validators.
class CustomerValidator
include Hanami::Validations
validations do
required(:email) { … }
required(:address).schema(AddressValidator)
end
end
Again, there is no limit to the nesting levels.
class OrderValidator
include Hanami::Validations
validations do
required(:number) { … }
required(:customer).schema(CustomerValidator)
end
end
In the end, OrderValidator
is able to validate a complex data structure like this:
{
number: "123",
customer: {
email: "user@example.com",
address: {
city: "Rome"
}
}
}
Another fundamental role that validators plays in the architecture of our projects is input whitelisting. For security reasons, we want to allow known keys to come in and reject everything else.
This process happens when we invoke #validate
.
Allowed keys are the ones defined with .required
.
Please note that whitelisting is only available for Hanami::Validations::Form
mixin.
When we trigger the validation process with #validate
, we get a result object in return. It’s able to tell if it’s successful, which rules the input data has violated and an output data bag.
result = OrderValidator.new({}).validate
result.success? # => false
result.messages
returns a nested set of validation error messages.
Each error carries on informations about a single rule violation.
result.messages.fetch(:number) # => ["is missing"]
result.messages.fetch(:customer) # => ["is missing"]
result.output
is a Hash
which is the result of whitelisting and coercions. It’s useful to pass it do other components that may want to persist that data.
{
"number" => "123",
"unknown" => "foo"
}
If we receive the input above, output
will look like this.
result.output
# => { :number => 123 }
We can observe that:
- Keys are symbolized
- Only whitelisted keys are included
- Data is coerced
To pick the right error message is crucial for user experience.
As usual Hanami::Validations
comes to the rescue for most common cases and it leaves space to customization of behaviors.
We have seen that builtin predicates have default messages, while inline predicates allow to specify a custom message via the :message
option.
class SignupValidator
include Hanami::Validations
predicate :email?, message: 'must be an email' do |current|
# ...
end
validations do
required(:email).filled(:str?, :email?)
required(:age).filled(:int?, gt?: 18)
end
end
result = SignupValidator.new(email: 'foo', age: 1).validate
result.success? # => false
result.messages.fetch(:email) # => ['must be an email']
result.messages.fetch(:age) # => ['must be greater than 18']
Inline error messages are ideal for quick and dirty development, but we suggest to use an external YAML file to configure these messages:
# config/messages.yml
en:
errors:
email?: "must be an email"
To be used like this:
class SignupValidator
include Hanami::Validations
messages_path 'config/messages.yml'
predicate :email? do |current|
# ...
end
validations do
required(:email).filled(:str?, :email?)
required(:age).filled(:int?, gt?: 18)
end
end
In the example above, the failure message for age is fine: "must be greater than 18"
, but how to tweak it? What if we need to change into something diffent? Again, we can use the YAML configuration file for our purpose.
# config/messages.yml
en:
errors:
email?: "must be an email"
rules:
signup:
age:
gt?: "must be an adult"
Now our validator is able to look at the right error message.
result = SignupValidator.new(email: 'foo', age: 1).validate
result.success? # => false
result.messages.fetch(:age) # => ['must be an adult']
⚠ CONVENTION: For a given validator named SignupValidator
, the framework will look for signup
translation key. ⚠
If for some reason that doesn't work for us, we can customize the namespace:
class SignupValidator
include Hanami::Validations
messages_path 'config/messages.yml'
namespace :my_signup
# ...
end
The new namespace should be used in the YAML file too.
# config/messages.yml
en:
# ...
rules:
my_signup:
age:
gt?: "must be an adult"
If your project already depends on i18n
gem, Hanami::Validations
is able to look at the translations defined for that gem and to use them.
class SignupValidator
include Hanami::Validations
messages :i18n
# ...
end
# config/locales/en.yml
en:
errors:
signup:
# ...
Uniqueness validation isn't implemented by Hanami::Validations
because the context of execution is completely decoupled from persistence.
Please remember that uniqueness validation is a huge race condition between application and the database, and it doesn't guarantee records uniqueness for real. To effectively enforce this policy you can use SQL database constraints.
Please read more at: The Perils of Uniqueness Validations.
Thanks to dry-rb Community for their priceless support. ❤️
hanami-validations
uses dry-validation as powerful low-level engine.
- Fork it ( https://github.com/hanami/validations/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request
Copyright © 2014-2021 Luca Guidi – Released under MIT License
This project was formerly known as Lotus (lotus-validations
).