hanami-validations/README.md

13 KiB
Raw Permalink Blame History

Hanami::Validations

Data validation library for Ruby

Status

Gem Version CI Test Coverage Depfu Inline Docs

Version

This branch contains the code for hanami-validations 2.x.

Contact

Rubies

Hanami::Validations supports Ruby (MRI) 3.0+

Installation

Add this line to your application's Gemfile:

gem "hanami-validations"

And then execute:

$ bundle

Or install it yourself as:

$ gem install hanami-validations

Usage

Hanami, ROM, and DRY projects are working together to create a strong Ruby ecosystem. hanami-validations is based on dry-validation, 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.

# 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.

# 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.

# 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.

# 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:

# 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

# 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.

# 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

# 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.

# 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.

# 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

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.

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")

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.

# 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.

# 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.

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 © 2014-2021 Luca Guidi Released under MIT License

This project was formerly known as Lotus (lotus-validations).