499 lines
13 KiB
Markdown
499 lines
13 KiB
Markdown
# Hanami::Validations
|
||
|
||
Data validation library for Ruby
|
||
|
||
## Status
|
||
|
||
[![Gem Version](https://badge.fury.io/rb/hanami-validations.svg)](https://badge.fury.io/rb/hanami-validations)
|
||
[![CI](https://github.com/hanami/validations/workflows/ci/badge.svg?branch=main)](https://github.com/hanami/validations/actions?query=workflow%3Aci+branch%3Amain)
|
||
[![Test Coverage](https://codecov.io/gh/hanami/validations/branch/main/graph/badge.svg)](https://codecov.io/gh/hanami/validations)
|
||
[![Depfu](https://badges.depfu.com/badges/af6c6be539d9d587c7541ae7a013c9ff/overview.svg)](https://depfu.com/github/hanami/validations?project=Bundler)
|
||
[![Inline Docs](http://inch-ci.org/github/hanami/validations.svg)](http://inch-ci.org/github/hanami/validations)
|
||
|
||
## Version
|
||
|
||
**This branch contains the code for `hanami-validations` 2.x.**
|
||
|
||
## Contact
|
||
|
||
* 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
|
||
|
||
## Rubies
|
||
|
||
__Hanami::Validations__ supports Ruby (MRI) 3.0+
|
||
|
||
## Installation
|
||
|
||
Add this line to your application's Gemfile:
|
||
|
||
```ruby
|
||
gem "hanami-validations"
|
||
```
|
||
|
||
And then execute:
|
||
|
||
```shell
|
||
$ bundle
|
||
```
|
||
|
||
Or install it yourself as:
|
||
|
||
```shell
|
||
$ gem install hanami-validations
|
||
```
|
||
|
||
## Usage
|
||
|
||
[Hanami](http://hanamirb.org), [ROM](https://rom-rb.org), and [DRY](https://dry-rb.org) projects are working together to create a strong Ruby ecosystem.
|
||
`hanami-validations` is based on [`dry-validation`](https://dry-rb.org/gems/dry-validation/master/), for this reason the documentation explains the basics of this gem, but for advanced topics, it links to `dry-validation` docs.
|
||
|
||
### Overview
|
||
|
||
The main object provided by this gem is `Hanami::Validator`.
|
||
It providers a powerful DSL to define a validation contract, which is made of a schema and optional rules.
|
||
|
||
A validation **schema** is a set of steps that filters, coerces, and checks the validity of incoming data.
|
||
Validation **rules** are a set of directives, to check if business rules are respected.
|
||
|
||
Only when the input is formally valid (according to the **schema**), validation **rules** are checked.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class SignupValidator < Hanami::Validator
|
||
schema do
|
||
required(:email).value(:string)
|
||
required(:age).value(:integer)
|
||
end
|
||
|
||
rule(:age) do
|
||
key.failure("must be greater than 18") if value < 18
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: 37)
|
||
result.success? # => true
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: "foo")
|
||
result.success? # => false
|
||
result.errors.to_h # => {:age=>["must be an integer"]}
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: 17)
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:age=>["must be greater than 18"]}
|
||
```
|
||
|
||
### Schemas
|
||
|
||
A basic schema doesn't apply data coercion, input must already have the right Ruby types.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class SignupValidator < Hanami::Validator
|
||
schema do
|
||
required(:email).value(:string)
|
||
required(:age).value(:integer)
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: 37)
|
||
puts result.success? # => true
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: "37")
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:age=>["must be an integer"]}
|
||
```
|
||
|
||
### Params
|
||
|
||
When used in _params mode_, a schema applies data coercion, before to run validation checks.
|
||
|
||
This is designed for Web form/HTTP params.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "bundler/setup"
|
||
require "hanami/validations"
|
||
|
||
class SignupValidator < Hanami::Validator
|
||
params do
|
||
required(:email).value(:string)
|
||
required(:age).value(:integer)
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: "37")
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:email=>"user@hanamirb.test", :age=>37}
|
||
```
|
||
|
||
### JSON
|
||
|
||
When used in _JSON mode_, data coercions are still applied, but they follow different policies.
|
||
For instance, because JSON supports integers, strings won't be coerced into integers.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class SignupValidator < Hanami::Validator
|
||
json do
|
||
required(:email).value(:string)
|
||
required(:age).value(:integer)
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: 37)
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:email=>"user@hanamirb.test", :age=>37}
|
||
|
||
result = validator.call(email: "user@hanamirb.test", age: "37")
|
||
puts result.success? # => false
|
||
```
|
||
|
||
### Whitelisting
|
||
|
||
Unknown keys from incoming data are filtered out:
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class SignupValidator < Hanami::Validator
|
||
schema do
|
||
required(:email).value(:string)
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: "user@hanamirb.test", foo: "bar")
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:email=>"user@hanamirb.test"}
|
||
```
|
||
|
||
### Custom Types
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
module Types
|
||
include Dry::Types()
|
||
|
||
StrippedString = Types::String.constructor(&:strip)
|
||
end
|
||
|
||
class SignupValidator < Hanami::Validator
|
||
params do
|
||
required(:email).value(Types::StrippedString)
|
||
required(:age).value(:integer)
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: " user@hanamirb.test ", age: "37")
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:email=>"user@hanamirb.test", :age=>37}
|
||
```
|
||
|
||
### Rules
|
||
|
||
Rules are performing a set of domain-specific validation checks.
|
||
Rules are executed only after the validations from the schema are satisfied.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class EventValidator < Hanami::Validator
|
||
params do
|
||
required(:start_date).value(:date)
|
||
end
|
||
|
||
rule(:start_date) do
|
||
key.failure("must be in the future") if value <= Date.today
|
||
end
|
||
end
|
||
|
||
validator = EventValidator.new
|
||
|
||
result = validator.call(start_date: "foo")
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:start_date=>["must be a date"]}
|
||
|
||
result = validator.call(start_date: Date.today)
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:start_date=>["must be in the future"]}
|
||
|
||
result = validator.call(start_date: Date.today + 1)
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:start_date=>#<Date: 2019-07-03 ((2458668j,0s,0n),+0s,2299161j)>}
|
||
```
|
||
|
||
Learn more about rules: https://dry-rb.org/gems/dry-validation/master/rules/
|
||
|
||
### Inheritance
|
||
|
||
Schema and rules validations can be inherited and used by subclasses
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class ApplicationValidator < Hanami::Validator
|
||
params do
|
||
optional(:_csrf_token).filled(:string)
|
||
end
|
||
end
|
||
|
||
class SignupValidator < ApplicationValidator
|
||
params do
|
||
required(:user).hash do
|
||
required(:email).filled(:string)
|
||
end
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(user: { email: "user@hanamirb.test" }, _csrf_token: "abc123")
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:user=>{:email=>"user@hanamirb.test"}, :_csrf_token=>"abc123"}
|
||
```
|
||
|
||
### Messages
|
||
|
||
Failure messages can be hardcoded or refer to a message template system.
|
||
`hanami-validations` supports natively a default YAML based message template system, or alternatively, `i18n` gem.
|
||
|
||
We have already seen rule failures set with hardcoded messages, here's an example of how to use keys to refer to interpolated messages.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class ApplicationValidator < Hanami::Validator
|
||
config.messages.top_namespace = "bookshelf"
|
||
config.messages.load_paths << "config/errors.yml"
|
||
end
|
||
```
|
||
|
||
In the `ApplicationValidator` there is defined the application namespace (`"bookshelf"`), which is the root of the messages file.
|
||
Below that top name, there is the key `errors`. Everything that is nested here is accessible by the validations.
|
||
|
||
There are two ways to organize messages:
|
||
|
||
1. Right below `errors`. This is for **general purposes** error messages (e.g. `bookshelf` => `errors` => `taken`)
|
||
2. Below `errors` => `rules` => name of the attribute => custom key (e.g. `bookshelf` => `errors` => `age` => `invalid`). This is for **specific** messages that affect only a specific attribute.
|
||
|
||
Our **suggestion** is to start with **specific** messages and see if there is a need to generalize them.
|
||
|
||
```yaml
|
||
# config/errors.yml
|
||
en:
|
||
bookshelf:
|
||
errors:
|
||
taken: "oh noes, it's already taken"
|
||
network: "there is a network error (%{code})"
|
||
rules:
|
||
age:
|
||
invalid: "must be greater than 18"
|
||
email:
|
||
invalid: "not a valid email"
|
||
```
|
||
|
||
#### General purpose messages
|
||
|
||
```ruby
|
||
class SignupValidator < ApplicationValidator
|
||
schema do
|
||
required(:username).filled(:string)
|
||
end
|
||
|
||
rule(:username) do
|
||
key.failure(:taken) if values[:username] == "jodosha"
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(username: "foo")
|
||
puts result.success? # => true
|
||
|
||
result = validator.call(username: "jodosha")
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:username=>["oh noes, it's already taken"]}
|
||
```
|
||
|
||
#### Specific messages
|
||
|
||
Please note that the failure key used it's the same for both the attributes (`:invalid`), but thanks to the nesting, the library is able to lookup the right message.
|
||
|
||
```ruby
|
||
class SignupValidator < ApplicationValidator
|
||
schema do
|
||
required(:email).filled(:string)
|
||
required(:age).filled(:integer)
|
||
end
|
||
|
||
rule(:email) do
|
||
key.failure(:invalid) unless values[:email] =~ /@/
|
||
end
|
||
|
||
rule(:age) do
|
||
key.failure(:invalid) if values[:age] < 18
|
||
end
|
||
end
|
||
|
||
validator = SignupValidator.new
|
||
|
||
result = validator.call(email: "foo", age: 17)
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:email=>["not a valid email"], :age=>["must be greater than 18"]}
|
||
```
|
||
|
||
#### Extra information
|
||
|
||
The interpolation mechanism, accepts extra, arbitrary information expressed as a `Hash` (e.g. `code: "123"`)
|
||
|
||
```ruby
|
||
class RefundValidator < ApplicationValidator
|
||
schema do
|
||
required(:refunded_code).filled(:string)
|
||
end
|
||
|
||
rule(:refunded_code) do
|
||
key.failure(:network, code: "123") if values[:refunded_code] == "error"
|
||
end
|
||
end
|
||
|
||
validator = RefundValidator.new
|
||
|
||
result = validator.call(refunded_code: "error")
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:refunded_code=>["there is a network error (123)"]}
|
||
```
|
||
|
||
Learn more about messages: https://dry-rb.org/gems/dry-validation/master/messages/
|
||
|
||
### External dependencies
|
||
|
||
If the validator needs to plug one or more objects to run the validations, there is a DSL to do so: `:option`.
|
||
When the validator is instantiated, the declared dependencies must be passed.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class AddressValidator
|
||
def valid?(value)
|
||
value.match(/Rome/)
|
||
end
|
||
end
|
||
|
||
class DeliveryValidator < Hanami::Validator
|
||
option :address_validator
|
||
|
||
schema do
|
||
required(:address).filled(:string)
|
||
end
|
||
|
||
rule(:address) do
|
||
key.failure("not a valid address") unless address_validator.valid?(values[:address])
|
||
end
|
||
end
|
||
|
||
validator = DeliveryValidator.new(address_validator: AddressValidator.new)
|
||
|
||
result = validator.call(address: "foo")
|
||
puts result.success? # => false
|
||
puts result.errors.to_h # => {:address=>["not a valid address"]}
|
||
```
|
||
|
||
Read more about external dependencies: https://dry-rb.org/gems/dry-validation/master/external-dependencies/
|
||
|
||
### Mixin
|
||
|
||
`hanami-validations` 1.x used to ship a mixin `Hanami::Validations` to be included in classes to provide validation rules.
|
||
The 2.x series, still ships this mixin, but it will be probably removed in 3.x.
|
||
|
||
```ruby
|
||
# frozen_string_literal: true
|
||
|
||
require "hanami/validations"
|
||
|
||
class UserValidator
|
||
include Hanami::Validations
|
||
|
||
validations do
|
||
required(:number).filled(:integer, eql?: 23)
|
||
end
|
||
end
|
||
|
||
result = UserValidator.new(number: 23).validate
|
||
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:number=>23}
|
||
puts result.errors.to_h # => {}
|
||
|
||
result = UserValidator.new(number: 11).validate
|
||
|
||
puts result.success? # => true
|
||
puts result.to_h # => {:number=>21}
|
||
puts result.errors.to_h # => {:number=>["must be equal to 23"]}
|
||
```
|
||
|
||
## FAQs
|
||
### Uniqueness Validation
|
||
|
||
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](http://robots.thoughtbot.com/the-perils-of-uniqueness-validations).
|
||
|
||
If you need to implement it, please use the External dependencies feature (see above).
|
||
|
||
## Contributing
|
||
|
||
1. Fork it ( https://github.com/hanami/validations/fork )
|
||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||
4. Push to the branch (`git push origin my-new-feature`)
|
||
5. Create a new Pull Request
|
||
|
||
## Copyright
|
||
|
||
Copyright © 2014-2021 Luca Guidi – Released under MIT License
|
||
|
||
This project was formerly known as Lotus (`lotus-validations`).
|