Tram::Policy

Policy Object Pattern

Sponsored by Evil Martians

Gem Version Build Status Dependency Status Inline docs

Intro

Policy objects are responsible for context-related validation of objects, or mixes of objects. Here context-related means a validation doesn't check whether an object is valid by itself, but whether it is valid for some purpose (context). For example, we could ask if some article is ready (valid) to be published, etc.

There are several well-known interfaces exist for validation like ActiveModel::Validations, or its ActiveRecord extension in Rails, or PORO Dry::Validation. All of them focus on providing rich DSL-s for validation rules.

Tram::Policy follows another approach -- it uses simple Ruby methods for validation, but focuses on building both customizable and composable results of validation, namely their errors.

  • By customizable we mean adding any number of tags to validation error -- to allow filtering and sorting validation results.
  • By composable we mean a possibility to merge errors provided by one policy/validator to another, for building nested sets of well-focused policies.

Keeping this reasons in mind, let's go to some examples.

Synopsis

The gem uses Dry::Initializer interface for defining params and options for policy object instanses:

require "tram-policy"

class Article::ReadinessPolicy < Tram::Policy
  # required param for article to validate
  param  :article

  # memoized attributes of the article (you can set them explicitly in specs)
  option :title,    proc(&:to_s), default: -> { article.title }
  option :subtitle, proc(&:to_s), default: -> { article.subtitle }
  option :text,     proc(&:to_s), default: -> { article.text }

  # define what methods and in what order we should use to validate an article
  validate :title_presence
  validate :subtitle_presence
  validate { errors.add :empty, field: "text", level: "error" if text.empty? }

  private

  def title_presence
    return unless title.empty?
    # Adds an error with a message and a set of additional tags
    # You can use any tags, not only an attribute/field like in ActiveModel
    errors.add "Title is empty", field: "title", level: "error"
  end

  def subtitle_presence
    return unless subtitle.empty?
    # Notice that we can set another level
    errors.add "Subtitle is empty", field: "subtitle", level: "warning"
  end
end

Because validation is the only responsibility of a policy, we don't need to call it explicitly. Policy initializer will perform all the checks immediately, memoizing the results into errors array. The methods #valid?, #invalid? and #validate! just check those #errors.

You can treat an instance of policy object as immutable.

article = Article.new title: "A wonderful article", subtitle: "", text: ""
policy  = Article::ReadinessPolicy[article] # syntax sugar for constructor `new`

# Simple checks
policy.errors.any? # => true
policy.valid?      # => false
policy.invalid?    # => true
policy.validate!   # raises Tram::Policy::ValidationError

# Look at errors closer
policy.errors.count # => 2 (no subtitle, no text)
policy.errors.filter { |error| error.tags[:level] == "error" }.count # => 1
policy.errors.filter { |error| error.level == "error" }.count # => 1

# Error messages are already added under special key :message
policy.errors.map(&:message) # => ["Subtitle is empty", "Error translation for missed text"]

# A shortcut
policy.messages # => ["Subtitle is empty", "Error translation for missed text"]

# More verbose strings
policy.full_messages
# => [
#      'Subtitle is empty: {"field":"subtitle", "level":"warning"}'
#      'Error translation for missed text: {"field":"text", "level":"error"}'
#    ]

# You can use tags in checkers -- to add condition for errors to ignore
policy.valid? { |error| !%w(warning error).include? error.level } # => false
policy.valid? { |error| error.level != "disaster" }               # => true

# Notice the `invalid` takes a block with definitions for errors to count (not ignore)
policy.invalid? { |error| %w(warning error).include? error.level } # => true
policy.invalid? { |error| error.level == "disaster" }              # => false

policy.validate! { |error| error.level != "disaster" } # => nil (seems ok)

You can use errors in composition of policies:

class Article::PublicationPolicy < Tram::Policy
  param  :article
  option :selected, proc { |value| !!value } # enforce booleans

  validate :article_readiness
  validate :article_selection

  private

  def article_readiness
    # Collects errors tagged by level: "error" from "nested" policy
    readiness_errors = Article::ReadinessPolicy[article].errors.by_tags(level: "error")

    # Merges collected errors to the current ones.
    # New errors are also tagged by source: "readiness".
    errors.merge(readiness_errors, source: "readiness")
  end

  def article_selection
    errors.add "Not selected", field: "selected", level: "info" unless selected
  end
end

As mentioned above, sending a symbolic key to the errors#add means the key should be translated by I18n. The only magic under the hood concerns a scope for the translation. By default it is taken from the full name of current class prepended with "tram-policy".

You can redefine the scope by reloading private method .scope of the policy.

All tags are available as options:

class Article::PublicationPolicy < Tram::Policy
  # ...
  errors.add :empty, field: "text", level: "error"
  # ...
end
# /config/locales/en.yml
---
en:
  tram-policy:
    article/publication_policy:
      empty: "Validation %{level}: %{field} is empty"

This will provide error message "Validation error: text is empty".

The last thing to say is about exceptions. When you use validate! it raises Tram::Policy::ValidationError (subclass of RuntimeError). Its message is built from selected errors (taking into account a validation! filter).

The exception also carries a backreference to the policy that raised it. You can use it to extract either errors, or arguments of the policy during a debugging:

begin
  policy.validate!
rescue Tram::Policy::ValidationError => error
  error.policy == policy # => true
end

Additional options

Class method .validate supports several options:

stop_on_faiure

If a selected validation will fail (adds an error to the collection), the following validations won't be executed.

require "tram-policy"

class Article::ReadinessPolicy < Tram::Policy
  # required param for article to validate
  param  :article

  validate :title_presence, stop_on_failure: true
  validate :title_valid # not executed if title is absent

  # ...
end

RSpec matchers

RSpec matchers defined in a file tram-policy/matcher (not loaded in runtime).

Use be_invalid_at matcher to check whether a policy has errors with given tags.

# app/policies/user/readiness_policy.rb
class User::ReadinessPolicy < Tram::Policy
  option :name,  proc(&:to_s), optional: true
  option :email, proc(&:to_s), optional: true

  validate :name_presence

  private

  def name_presence
    return unless name.empty?
    errors.add "Name is absent", level: "error"
  end
end
# spec/spec_helper.rb
require "tram/policy/rspec"
# spec/policies/user/readiness_policy_spec.rb
RSpec.describe User::ReadinessPolicy do
  let(:user) { build :user } # <- expected a factory

  subject(:policy) { described_class[email: "[email protected]"] }

  it "is invalid with 'error' level" do
    expect { policy }.to be_invalid_at level: "error"
  end

  it "is not invalid with 'info' level" do
    expect { policy }.not_to be_invalid_at level: "info"
  end
end

Notice that you have to wrap policy into block { policy }. This is because the matcher checks not only presence of an error, but also ensures its message is translated to all available locales (I18n.available_locales). The block containing a policy will be executed separately for every such language.

Generators

The gem provides simple tool for scaffolding new policy along with RSpec test template.

$ tram-policy user/readiness_policy -p user -o admin -v name_present:blank_name email_present:blank_email

This will generate a policy class with specification compatible to both RSpec and FactoryGirl:

# app/policies/user/readiness_policy.rb

# TODO: describe the policy, its subject and context
class User::ReadinessPolicy < Tram::Policy
  # TODO: add default values     (default: -> { ... }),
  #       coercers               (type: proc(&:to_s)),
  #       and optional arguments (optional: true)
  #       when necessary
  param  :user
  option :admin

  validate :name_present
  validate :email_present

  private

  def name_present
    # TODO: define a condition
    return if true
    # TODO: add necessary tags
    errors.add :blank_name
  end

  def email_present
    # TODO: define a condition
    return if true
    # TODO: add necessary tags
    errors.add :blank_email
  end
end
# config/tram-policies.en.yml
---
en:
  tram-policy:
    user/readiness_policy:
      blank_name: translation missing
      blank_email: translation missing
# spec/policies/user/readiness_policy_spec.rb
require "spec_helper"
# TODO: move it to spec_helper
require "tram/policy/rspec"

RSpec.describe User::ReadinessPolicy, ".[]" do
  # TODO: either remove this line, or set another source for locales to check
  let(:available_locales) { I18n.available_locales }
  let(:user) { FactoryGirl.build :user }

  it "is valid with proper arguments" do
    expect { described_class[user] }.to be_valid
  end

  # TODO: check the description
  it "is invalid when not name_present" do
    # TODO: modify some arguments
    user = nil
    # TODO: add necessary tags to focus the condition
    expect { described_class[user] }.to be_invalid_at
  end

  # TODO: check the description
  it "is invalid when not email_present" do
    # TODO: modify some arguments
    user = nil
    # TODO: add necessary tags to focus the condition
    expect { described_class[user] }.to be_invalid_at
  end
end

Then you should go through all TODO-s and add necessary details.

Later you can copy-paste examples to provide more edge case for testing your policies.

Notice that RSpec matcher be_invalid_at checks at once:

  • that an error is added to the policy
  • that the error has given tags
  • that the error is translated to every available locale

Its negation (not_to be_invalid_at) checks that no errors added with given tags. When called without tags, it checks that the policy is valid as a whole.

Both matchers provide a full description for the essence of the failure.

To Recap

The Tram::Policy DSL provides the following methods:

  • .param and .option - class-level methods for policy constructor arguments
  • .validate - class-level method to add validators (they will be invoked in the same order as defined)
  • .[] - a syntax sugar for .new

  • #errors - returns an enumerable collection of validation errors

  • #valid? - checks whether no errors exist

  • #invalid? - checks whether some error exists

  • #validate! - raises if some error exist

Enumerable collection of unique policy errors (Tram::Policy::Errors) responds to methods:

  • add - adds an error to the collection
  • each - iterates by the set of errors (support other methods of enumerables)
  • empty? - checks whether a collection is emtpy (in addition to enumerable interface)
  • by_tags - filters errors that have given tags
  • messages - returns an array of messages
  • full_messages - returns an array of messages with tags info added (used in exception)
  • merge - merges a collection to another one

Every instance of Tram::Policy::Error supports:

  • #tags - hash of assigned tags
  • #message - the translated message
  • #full_message - the message with tags info added
  • #to_h - hash of tags and a message
  • #== - checks whether an error is equal to another one
  • undefined methods treated as tags

The instance of Tram::Policy::ValidationError responds to:

  • policy - returns a policy object that raised an exception
  • other methods defined by the RuntimeError class

Installation

Add this line to your application's Gemfile:

gem 'tram-policy'

And then execute:

$ bundle

Or install it yourself as:

$ gem install tram-policy

License

The gem is available as open source under the terms of the MIT License.