{"id":13879418,"url":"https://github.com/tram-rb/tram-policy","last_synced_at":"2025-07-16T15:32:20.610Z","repository":{"id":19327709,"uuid":"86795031","full_name":"tram-rb/tram-policy","owner":"tram-rb","description":"Policy Object Pattern","archived":false,"fork":false,"pushed_at":"2022-06-18T12:45:43.000Z","size":178,"stargazers_count":19,"open_issues_count":0,"forks_count":8,"subscribers_count":3,"default_branch":"master","last_synced_at":"2024-09-22T03:11:52.059Z","etag":null,"topics":["errors","patterns","policy","validation","validator","validators"],"latest_commit_sha":null,"homepage":null,"language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tram-rb.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2017-03-31T08:14:14.000Z","updated_at":"2023-08-04T09:56:36.000Z","dependencies_parsed_at":"2022-08-20T17:40:36.315Z","dependency_job_id":null,"html_url":"https://github.com/tram-rb/tram-policy","commit_stats":null,"previous_names":[],"tags_count":20,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tram-rb%2Ftram-policy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tram-rb%2Ftram-policy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tram-rb%2Ftram-policy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tram-rb%2Ftram-policy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tram-rb","download_url":"https://codeload.github.com/tram-rb/tram-policy/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":226143895,"owners_count":17580245,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["errors","patterns","policy","validation","validator","validators"],"created_at":"2024-08-06T08:02:20.372Z","updated_at":"2024-11-24T08:31:20.265Z","avatar_url":"https://github.com/tram-rb.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# Tram::Policy\n\nPolicy Object Pattern\n\n\u003ca href=\"https://evilmartians.com/\"\u003e\n\u003cimg src=\"https://evilmartians.com/badges/sponsored-by-evil-martians.svg\" alt=\"Sponsored by Evil Martians\" width=\"236\" height=\"54\"\u003e\u003c/a\u003e\n\n[![Gem Version][gem-badger]][gem]\n[![Inline docs][inch-badger]][inch]\n\n## Intro\n\nPolicy 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.\n\nThere are several well-known interfaces exist for validation like [ActiveModel::Validations][active-model-validation], or its [ActiveRecord][active-record-validation] extension for Rails, or PORO [Dry::Validation][dry-validation]. All of them focus on providing rich DSL-s for **validation rules**.\n\n**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.\n\n- By **customizable** we mean adding any number of *tags* to errors -- to allow filtering and sorting validation results.\n- By **composable** we mean a possibility to merge errors provided by one policy into another, and build nested sets of well-focused policies.\n\nKeeping this reasons in mind, let's go to some examples.\n\n## Synopsis\n\nThe gem uses [Dry::Initializer][dry-initializer] interface for defining params and options for policy object instanses:\n\n```ruby\nrequire \"tram-policy\"\n\nclass Article::ReadinessPolicy \u003c Tram::Policy\n  # required param for article to validate\n  param  :article\n\n  # memoized attributes of the article (you can set them explicitly in specs)\n  option :title,    proc(\u0026:to_s), default: -\u003e { article.title }\n  option :subtitle, proc(\u0026:to_s), default: -\u003e { article.subtitle }\n  option :text,     proc(\u0026:to_s), default: -\u003e { article.text }\n\n  # define what methods and in what order we should use to validate an article\n  validate :title_presence\n  validate :subtitle_presence\n  validate do # use anonymous lambda\n    return unless text.empty?\n    errors.add :empty, field: \"text\", level: \"error\"\n  end\n\n  private\n\n  def title_presence\n    return unless title.empty?\n    # Adds an error with a unique key and a set of additional tags\n    # You can use any tags, not only an attribute/field like in ActiveModel\n    errors.add :blank_title, field: \"title\", level: \"error\"\n  end\n\n  def subtitle_presence\n    return unless subtitle.empty?\n    # Notice that we can set another level\n    errors.add :blank_subtitle, field: \"subtitle\", level: \"warning\"\n  end\nend\n```\n\nBecause validation is the only responsibility of a policy, we don't need to call it explicitly.\n\nPolicy initializer will perform all the checks immediately, memoizing the results into `errors` array. The methods `#valid?`, `#invalid?` and `#validate!` just check those `#errors`.\n\nYou should treat an instance immutable.\n\n```ruby\narticle = Article.new title: \"A wonderful article\", subtitle: \"\", text: \"\"\npolicy  = Article::ReadinessPolicy[article] # syntax sugar for constructor `new`\n\n# Simple checks\npolicy.errors.any? # =\u003e true\npolicy.valid?      # =\u003e false\npolicy.invalid?    # =\u003e true\npolicy.validate!   # raises Tram::Policy::ValidationError\n\n# And errors\npolicy.errors.count # =\u003e 2 (no subtitle, no text)\npolicy.errors.filter { |error| error.tags[:level] == \"error\" }.count # =\u003e 1\npolicy.errors.filter { |error| error.level == \"error\" }.count # =\u003e 1\n```\n\n## Validation Results\n\nLet look at those errors closer. We define 3 representation of errors:\n\n- error objects (`policy.errors`)\n- error items (`policy.items`, `policy.errors.items`, `policy.errors.map(\u0026:item)`)\n- error messages (`policy.messages`, `policy.errors.messages`, `policy.errors.map(\u0026:message)`)\n\nErrors by themselves are used for composition (see the next chapter), while `items` and `messages` represent errors for translation.\n\nThe difference is the following.\n\n- The `messages` are translated immediately using the current locale.\n\n- The `items` postpone translation for later (for example, you can store them in a database and translate them to the locale of UI by demand).\n\n### Items\n\nError items contain arrays that could be send to I18n.t for translation. We add the default scope from the name of policy, preceeded by the `[\"tram-policy\"]` root namespace.\n\n```ruby\npolicy.items # or policy.errors.items, or policy.errors.map(\u0026:item)\n# =\u003e [\n#      [\n#        :blank_title,\n#        {\n#          scope: [\"tram-policy\", \"article/readiness_policy\"]],\n#          field: \"title\",\n#          level: \"error\"\n#        }\n#      ],\n#      ...\n#    ]\n\nI18n.t(*policy.items.first)\n# =\u003e \"translation missing: en.tram-policy.article/readiness_policy.blank_title\"\n```\n\nYou can change the root scope if you will (this could be useful in libraries):\n\n```ruby\nclass MyGemPolicy \u003c Tram::Policy\n  root_scope \"mygem\", \"policies\" # inherited by subclasses\nend\n\nclass Article::ReadinessPolicy \u003c MyGemPolicy\n  # ...\nend\n\n# ...\nI18n.t(*policy.items.first)\n# =\u003e \"translation missing: en.mygem.policies.article/readiness_policy.blank_title\"\n```\n\n### Messages\n\nError messages contain translation of `policy.items` in the current locale:\n\n```ruby\npolicy.messages # or policy.errors.messages, or policy.errors.map(\u0026:message)\n# =\u003e [\n#      \"translation missing: en.tram-policy.article/readiness_policy.blank_title\",\n#      \"translation missing: en.tram-policy.article/readiness_policy.blank_subtitle\"\n#    ]\n```\n\nThe messages are translated if the keys are symbolic. Strings are treated as already translated:\n\n```ruby\nclass Article::ReadinessPolicy \u003c Tram::Policy\n  # ...\n  def title_presence\n    return unless title.empty?\n    errors.add \"Title is absent\", field: \"title\", level: \"error\"\n  end\nend\n\n# ...\npolicy.messages\n# =\u003e [\n#      \"Title is absent\",\n#      \"translation missing: en.tram-policy.article/readiness_policy.blank_subtitle\"\n#    ]\n```\n\n## Partial Validation\n\nYou can use tags in checkers -- to add condition for errors to ignore\n\n```ruby\npolicy.valid? { |error| !%w(warning error).include? error.level } # =\u003e false\npolicy.valid? { |error| error.level != \"disaster\" }               # =\u003e true\n```\n\nNotice the `invalid?` method takes a block with definitions for errors to count (not ignore)\n\n```ruby\npolicy.invalid? { |error| %w(warning error).include? error.level } # =\u003e true\npolicy.invalid? { |error| error.level == \"disaster\" }              # =\u003e false\n\npolicy.validate! { |error| error.level != \"disaster\" } # =\u003e nil (seems ok)\n```\n\n## Composition of Policies\n\nYou can use errors in composition of policies:\n\n```ruby\nclass Article::PublicationPolicy \u003c Tram::Policy\n  param  :article\n  option :selected, proc { |value| !!value } # enforce booleans\n\n  validate :article_readiness\n  validate :article_selection\n\n  private\n\n  def article_readiness\n    # Collects errors tagged by level: \"error\" from \"nested\" policy\n    readiness_errors = Article::ReadinessPolicy[article].errors.filter(level: \"error\")\n\n    # Merges collected errors to the current ones.\n    # New errors are also tagged by source: \"readiness\".\n    errors.merge(readiness_errors, source: \"readiness\")\n  end\n\n  def article_selection\n    errors.add \"Not selected\", field: \"selected\", level: \"info\" unless selected\n  end\nend\n```\n\n## Exceptions\n\nWhen you use `validate!` it raises `Tram::Policy::ValidationError` (subclass of `RuntimeError`). Its message is built from selected errors (taking into account a `validation!` filter).\n\nThe 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:\n\n```ruby\nbegin\n  policy.validate!\nrescue Tram::Policy::ValidationError =\u003e error\n  error.policy == policy # =\u003e true\nend\n```\n\n## Additional options\n\nClass method `.validate` supports several options:\n\n### `stop_on_failure`\n\nIf a selected validation will fail (adds an error to the collection), the following validations won't be executed.\n\n```ruby\nrequire \"tram-policy\"\n\nclass Article::ReadinessPolicy \u003c Tram::Policy\n  # required param for article to validate\n  param  :article\n\n  validate :title_presence, stop_on_failure: true\n  validate :title_valid # not executed if title is absent\n\n  # ...\nend\n```\n\n## RSpec matchers\n\nRSpec matchers defined in a file `tram-policy/matcher` (not loaded in runtime).\n\nUse `be_invalid_at` matcher to check whether a policy has errors with given tags.\n\n```ruby\n# app/policies/user/readiness_policy.rb\nclass User::ReadinessPolicy \u003c Tram::Policy\n  option :name,  proc(\u0026:to_s), optional: true\n  option :email, proc(\u0026:to_s), optional: true\n\n  validate :name_presence\n\n  private\n\n  def name_presence\n    return unless name.empty?\n    errors.add \"Name is absent\", level: \"error\"\n  end\nend\n```\n\n```ruby\n# spec/spec_helper.rb\nrequire \"tram/policy/rspec\"\n```\n\n```ruby\n# spec/policies/user/readiness_policy_spec.rb\nRSpec.describe User::ReadinessPolicy do\n  subject(:policy) { described_class[email: \"user@example.com\"] }\n\n  let(:user) { build :user } # \u003c- expected a factory\n\n  it { is_expected.to be_invalid }\n  it { is_expected.to be_invalid_at level: \"error\" }\n  it { is_expected.to be_valid_at   level: \"info\" }\nend\n```\n\nThe matcher checks not only the presence of an error, but also ensures that you provided translation of any message to any available locale (`I18n.available_locales`).\n\n## Generators\n\nThe gem provides simple tool for scaffolding new policy along with its RSpec test template and translations.\n\n```shell\n$ tram-policy user/readiness_policy -p user -o admin -v name_present:blank_name email_present:blank_email\n```\n\nThis will generate a policy class with specification compatible to both [RSpec][rspec] and [FactoryBot][factory_bot].\n\nUnder the keys `-p` and `-o` define params and options of the policy.\nKey `-v` should contain validation methods along with their error message keys.\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'tram-policy'\n```\n\nAnd then execute:\n\n```shell\n$ bundle\n```\n\nOr install it yourself as:\n\n```shell\n$ gem install tram-policy\n```\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).\n\n[codeclimate-badger]: https://img.shields.io/codeclimate/github/tram-rb/tram-policy.svg?style=flat\n[codeclimate]: https://codeclimate.com/github/tram-rb/tram-policy\n[gem-badger]: https://img.shields.io/gem/v/tram-policy.svg?style=flat\n[gem]: https://rubygems.org/gems/tram-policy\n[inch-badger]: http://inch-ci.org/github/tram-rb/tram-policy.svg\n[inch]: https://inch-ci.org/github/tram-rb/tram-policy\n[travis-badger]: https://img.shields.io/travis/tram-rb/tram-policy/master.svg?style=flat\n[active-model-validation]: http://api.rubyonrails.org/classes/ActiveModel/Validations.html\n[active-record-validation]: http://guides.rubyonrails.org/active_record_validations.html\n[dry-validation]: http://dry-rb.org/gems/dry-validation/\n[dry-initializer]: http://dry-rb.org/gems/dry-initializer/\n[i18n]: https://github.com/svenfuchs/i18n\n[rspec]: http://rspec.info/\n[factory_bot]: https://github.com/thoughtbot/factory_bot\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftram-rb%2Ftram-policy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftram-rb%2Ftram-policy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftram-rb%2Ftram-policy/lists"}