├── .codeclimate.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── tram-policy ├── lib ├── tram-policy.rb └── tram │ ├── policy.rb │ └── policy │ ├── dsl.rb │ ├── error.rb │ ├── errors.rb │ ├── generator.rb │ ├── generator │ ├── policy.erb │ └── policy_spec.erb │ ├── inflector.rb │ ├── rspec.rb │ ├── validation_error.rb │ └── validator.rb ├── spec ├── fixtures │ ├── admin_policy.rb │ ├── customer_policy.rb │ ├── en.yml │ └── user_policy.rb ├── spec_helper.rb ├── support │ └── fixtures_helper.rb └── tram │ ├── policy │ ├── error_spec.rb │ ├── errors_spec.rb │ ├── inflector_spec.rb │ ├── rspec_spec.rb │ └── validation_error_spec.rb │ └── policy_spec.rb └── tram-policy.gemspec /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | rubocop: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | exclude_paths: 11 | - "benchmarks/**/*" 12 | - "spec/**/*" 13 | ratings: 14 | paths: 15 | - "lib/**/*" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | build: 13 | name: Ruby ${{ matrix.ruby }} 14 | 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | ruby: 19 | - "2.5.0" 20 | - "3.0.0" 21 | - "head" 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Install dependent libraries 30 | run: sudo apt-get install libpq-dev 31 | 32 | - name: Install Ruby ${{ matrix.ruby }} 33 | uses: ruby/setup-ruby@v1.61.1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | bundler-cache: true # 'bundle install' and cache 37 | 38 | - name: Check code style 39 | run: bundle exec rubocop 40 | continue-on-error: false 41 | 42 | - name: Run tests 43 | run: bundle exec rake --trace 44 | continue-on-error: false 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /*.gem 11 | .rspec_status 12 | .idea/ 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | --warnings 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AllCops: 3 | DisplayCopNames: true 4 | DisplayStyleGuide: true 5 | StyleGuideCopsOnly: true 6 | TargetRubyVersion: 3.0 7 | 8 | Lint/AmbiguousBlockAssociation: 9 | Enabled: false 10 | 11 | Naming/FileName: 12 | Exclude: 13 | - lib/tram-policy.rb 14 | 15 | Style/ClassAndModuleChildren: 16 | Enabled: false 17 | 18 | Style/PerlBackrefs: 19 | Enabled: false 20 | 21 | Style/RaiseArgs: 22 | EnforcedStyle: compact 23 | 24 | Style/StringLiterals: 25 | EnforcedStyle: double_quotes 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [2.2.0] - [2022-06-18] 8 | 9 | ### Added 10 | - Support for Ruby 3+ (HolyWalley) 11 | 12 | ## [2.1.0] - [2021-11-30] 13 | 14 | ### Fixed 15 | - Difference between last positional and keyword arguments in Ruby 3.0+ (mrexox) 16 | 17 | ## [2.0.1] - [2019-11-14] 18 | 19 | ### Fixed 20 | - Allow translation :scope to be customized in the #merge operation (sclinede) 21 | Before the fix, the customized value was always replaced by the default value. 22 | 23 | ## [2.0.0] - [2019-07-04] 24 | 25 | ### Changed 26 | 27 | - [BREAKING] separate `Tram::Policy::Errors` from a policy (nepalez, sclinede) 28 | 29 | Instead of the policy, the collection refers to the explicit scope used for error messages' translation. 30 | This change breaks the signature of `Tram::Policy::Error` and `Tram::Policy::Errors`. 31 | 32 | ## [1.0.1] - [2019-05-06] 33 | 34 | ### Added 35 | - Support of `dry-initializer` v3.0+ (nepalez) 36 | 37 | ### Fixed 38 | - Fix be_invalid RSpec matcher if non-policy model is passed (Envek) 39 | - Disable translation check for non-strings in rspec matcher (Envek) 40 | 41 | ## [1.0.0] - [2018-02-17] 42 | 43 | ### Changed 44 | - RSpec matchers does't use blocks any more (nepalez) 45 | 46 | Instead of 47 | 48 | ```ruby 49 | expect { policy }.to be_invalid_at level: "error" 50 | ``` 51 | 52 | use the simpler syntax 53 | 54 | ```ruby 55 | expect(policy).to be_invalid_at level: "error" 56 | ``` 57 | 58 | ### Deleted 59 | - Deprecated methods (nepalez) 60 | - RSpec shared examples (nepalez) 61 | 62 | ## [0.4.0] - [2018-02-17] 63 | 64 | This is beta-release before the first stable version 1.0.0. 65 | 66 | It adds methods `#item` and `#items` to policy errors to support lazy translation. 67 | 68 | It also renames some methods, and deprecate others that will be removed from v1.0.0. 69 | 70 | ### Added 71 | - `Tram::Policy.root_scope` changes the default root scope ("tram-policy") for I18n (nepalez) 72 | - `Tram::Policy::Error#item` returns an array of [key, tags] which can be sent to I18n.t later (nepalez) 73 | - `Tram::Policy::Error#to_a` as an alias for the `#item` (nepalez) 74 | - `Tram::Policy::Errors#items` returns an array of error items (nepalez) 75 | - `Tram::Policy::Errors#filter` acts like `by_tag` but returns the filtered collection instead of an array (nepalez) 76 | - `Tram::Policy#messages` as a shortcut for `errors.messages` (nepalez) 77 | - `Tram::Policy#items` as a shortcut for `errors.items` (nepalez) 78 | 79 | ### Changed 80 | - errors are compared by `#to_a` instead of `#to_h` (nepalez) 81 | 82 | ### Deprecated 83 | - `Tram::Policy::Error#full_message` (nepalez) 84 | - `Tram::Policy::Error#to_h` (nepalez) 85 | - `Tram::Policy::Errors#full_messages` (nepalez) 86 | - `Tram::Policy::Errors#by_tags` (nepalez) 87 | 88 | ## [0.3.1] - [2018-01-05] 89 | 90 | ### Fixed 91 | - Convertion of block into lambda in `validate` (nepalez) 92 | 93 | ## [0.3.0] - [2018-01-05] 94 | 95 | ### Added 96 | - Allow returning from block in `validate` by using lambdas (nepalez) 97 | 98 | ## [0.2.5] - [2018-01-05] 99 | 100 | ### Added 101 | - Allow `Tram::Policy.scope` to be made private (nepalez) 102 | 103 | ## [0.2.4] - [2017-12-03] 104 | 105 | Some private methods has been refactored 106 | 107 | ## Internals 108 | - Renamed `Tram::Policy@__options__` -> `Tram::Policy##__attributes__` (nepalez) 109 | - Removed `Tram::Policy::Validator##scope` in favor of `Tram::Policy.scope` (nepalez) 110 | - Refactored spec matcher (nepalez) 111 | 112 | ## [0.2.3] - [2017-11-21] 113 | 114 | ### Fixed 115 | - RSpec matcher `:be_invalid_at` checks all the available locales (nepalez) 116 | - Security vulnerability from old version of rubocop (update to 0.49) (nepalez) 117 | 118 | ## [0.2.2] - [2017-09-12] 119 | 120 | ### Changed 121 | - Policy class methods `all`, `local`, and `scope` was made public (nepalez) 122 | 123 | ## [0.2.1] - [2017-08-28] 124 | 125 | ### Changed 126 | - Updated dependency from [dry-initializer] to v2.0.0 (nepalez) 127 | 128 | ## [0.2.0] - [2017-08-19] 129 | 130 | ### Added 131 | - Support for unnamed block validators (@nepalez) 132 | 133 | In addition to instance methods: 134 | 135 | validate :some_method 136 | 137 | You can use a block for validation: 138 | 139 | validate { errors.add :blank_name if name.blank? } 140 | 141 | - Support for custom scopes (@nepalez) 142 | 143 | Just reload private class method `scope` 144 | 145 | - Support for inheritance (@nepalez) 146 | 147 | You can inherit the policy class. A subclass will apply all validators of 148 | superclass before those of its own. Every error message will be translated 149 | in the scope of policy where it was defined. 150 | 151 | ### Deleted 152 | - Reloading of validators 153 | 154 | To follow Liskov substitube principle we run all validators declared anywhere 155 | in the policy or its superclasses. Any sub-policy should provide the same 156 | level of confidence about validity of object(s) under check as any 157 | of its superclasses. 158 | 159 | ## [0.1.1] - [2017-08-04] 160 | 161 | ### Added 162 | - Support for options in errors.merge (@nepalez) 163 | 164 | # adds `field: "user"` to every merged error 165 | errors.merge other_policy.errors, field: "user" 166 | 167 | ## [0.1.0] - [2017-05-31] 168 | Contains backward-incompatible change. 169 | 170 | ### Migration 171 | You should add the namespace to gem-related I18n translations. 172 | 173 | ```yaml 174 | # config/locales/tram-policy.en.yml 175 | --- 176 | en: 177 | tram-policy: # The namespace to be added 178 | my_policy: 179 | error_key: Error message 180 | ``` 181 | 182 | ### Changed 183 | - [BREAKING] a namespace added to scopes for policy error translations (@nepalez) 184 | 185 | ## [0.0.3] - [2017-05-24] 186 | 187 | ### Fixed 188 | - bug in `errors.empty?` with a block (@nepalez with thanks to @gzigzigzeo) 189 | 190 | ## [0.0.2] - [2017-04-25] 191 | The gem is battle-tested for production (in a real commertial project). 192 | 193 | ### Removed 194 | - `.validate` with several keys for several validators at once (@nepalez) 195 | 196 | Use a multiline version instead of `validate :foo, :bar`: 197 | 198 | ```ruby 199 | validate :foo 200 | validate :bar 201 | ``` 202 | 203 | ### Added 204 | - `.validate` supports option `stop_on_failure` (@nepalez) 205 | 206 | ### Fixed 207 | - Minor bugs in generators (@nepalez) 208 | 209 | ## [0.0.1] - [2017-04-18] 210 | This is a first public release (@nepalez, @charlie-wasp, @JewelSam, @sergey-chechaev) 211 | 212 | [dry-initializer]: https://github.com/dry-rb/dry-initializer 213 | [Unreleased]: https://github.com/tram-rb/tram-policy 214 | [0.0.1]: https://github.com/tram-rb/tram-policy/releases/tag/v0.0.1 215 | [0.0.2]: https://github.com/tram-rb/tram-policy/compare/v0.0.1...v0.0.2 216 | [0.0.3]: https://github.com/tram-rb/tram-policy/compare/v0.0.2...v0.0.3 217 | [0.1.0]: https://github.com/tram-rb/tram-policy/compare/v0.0.3...v0.1.0 218 | [0.1.1]: https://github.com/tram-rb/tram-policy/compare/v0.1.0...v0.1.1 219 | [0.2.0]: https://github.com/tram-rb/tram-policy/compare/v0.1.1...v0.2.0 220 | [0.2.1]: https://github.com/tram-rb/tram-policy/compare/v0.2.0...v0.2.1 221 | [0.2.2]: https://github.com/tram-rb/tram-policy/compare/v0.2.1...v0.2.2 222 | [0.2.3]: https://github.com/tram-rb/tram-policy/compare/v0.2.2...v0.2.3 223 | [0.2.4]: https://github.com/tram-rb/tram-policy/compare/v0.2.3...v0.2.4 224 | [0.2.5]: https://github.com/tram-rb/tram-policy/compare/v0.2.4...v0.2.5 225 | [0.3.0]: https://github.com/tram-rb/tram-policy/compare/v0.2.5...v0.3.0 226 | [0.3.1]: https://github.com/tram-rb/tram-policy/compare/v0.3.0...v0.3.1 227 | [0.4.0]: https://github.com/tram-rb/tram-policy/compare/v0.3.1...v0.4.0 228 | [1.0.0]: https://github.com/tram-rb/tram-policy/compare/v0.4.0...v1.0.0 229 | [1.0.1]: https://github.com/tram-rb/tram-policy/compare/v1.0.0...v1.0.1 230 | [2.0.0]: https://github.com/tram-rb/tram-policy/compare/v1.0.1...v2.0.0 231 | [2.0.1]: https://github.com/tram-rb/tram-policy/compare/v2.0.0...v2.0.1 232 | [2.1.0]: https://github.com/tram-rb/tram-policy/compare/v2.0.1...v2.1.0 233 | [2.1.0]: https://github.com/tram-rb/tram-policy/compare/v2.1.0...v2.2.0 234 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem "pry", platform: :mri 7 | gem "pry-byebug", platform: :mri 8 | end 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Evil Martians, Andrew Kozin (nepalez), Viktor Sokolov (gzigzigzeo) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tram::Policy 2 | 3 | Policy Object Pattern 4 | 5 | 6 | Sponsored by Evil Martians 7 | 8 | [![Gem Version][gem-badger]][gem] 9 | [![Inline docs][inch-badger]][inch] 10 | 11 | ## Intro 12 | 13 | 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. 14 | 15 | There 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**. 16 | 17 | **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. 18 | 19 | - By **customizable** we mean adding any number of *tags* to errors -- to allow filtering and sorting validation results. 20 | - By **composable** we mean a possibility to merge errors provided by one policy into another, and build nested sets of well-focused policies. 21 | 22 | Keeping this reasons in mind, let's go to some examples. 23 | 24 | ## Synopsis 25 | 26 | The gem uses [Dry::Initializer][dry-initializer] interface for defining params and options for policy object instanses: 27 | 28 | ```ruby 29 | require "tram-policy" 30 | 31 | class Article::ReadinessPolicy < Tram::Policy 32 | # required param for article to validate 33 | param :article 34 | 35 | # memoized attributes of the article (you can set them explicitly in specs) 36 | option :title, proc(&:to_s), default: -> { article.title } 37 | option :subtitle, proc(&:to_s), default: -> { article.subtitle } 38 | option :text, proc(&:to_s), default: -> { article.text } 39 | 40 | # define what methods and in what order we should use to validate an article 41 | validate :title_presence 42 | validate :subtitle_presence 43 | validate do # use anonymous lambda 44 | return unless text.empty? 45 | errors.add :empty, field: "text", level: "error" 46 | end 47 | 48 | private 49 | 50 | def title_presence 51 | return unless title.empty? 52 | # Adds an error with a unique key and a set of additional tags 53 | # You can use any tags, not only an attribute/field like in ActiveModel 54 | errors.add :blank_title, field: "title", level: "error" 55 | end 56 | 57 | def subtitle_presence 58 | return unless subtitle.empty? 59 | # Notice that we can set another level 60 | errors.add :blank_subtitle, field: "subtitle", level: "warning" 61 | end 62 | end 63 | ``` 64 | 65 | Because validation is the only responsibility of a policy, we don't need to call it explicitly. 66 | 67 | Policy initializer will perform all the checks immediately, memoizing the results into `errors` array. The methods `#valid?`, `#invalid?` and `#validate!` just check those `#errors`. 68 | 69 | You should treat an instance immutable. 70 | 71 | ```ruby 72 | article = Article.new title: "A wonderful article", subtitle: "", text: "" 73 | policy = Article::ReadinessPolicy[article] # syntax sugar for constructor `new` 74 | 75 | # Simple checks 76 | policy.errors.any? # => true 77 | policy.valid? # => false 78 | policy.invalid? # => true 79 | policy.validate! # raises Tram::Policy::ValidationError 80 | 81 | # And errors 82 | policy.errors.count # => 2 (no subtitle, no text) 83 | policy.errors.filter { |error| error.tags[:level] == "error" }.count # => 1 84 | policy.errors.filter { |error| error.level == "error" }.count # => 1 85 | ``` 86 | 87 | ## Validation Results 88 | 89 | Let look at those errors closer. We define 3 representation of errors: 90 | 91 | - error objects (`policy.errors`) 92 | - error items (`policy.items`, `policy.errors.items`, `policy.errors.map(&:item)`) 93 | - error messages (`policy.messages`, `policy.errors.messages`, `policy.errors.map(&:message)`) 94 | 95 | Errors by themselves are used for composition (see the next chapter), while `items` and `messages` represent errors for translation. 96 | 97 | The difference is the following. 98 | 99 | - The `messages` are translated immediately using the current locale. 100 | 101 | - 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). 102 | 103 | ### Items 104 | 105 | Error 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. 106 | 107 | ```ruby 108 | policy.items # or policy.errors.items, or policy.errors.map(&:item) 109 | # => [ 110 | # [ 111 | # :blank_title, 112 | # { 113 | # scope: ["tram-policy", "article/readiness_policy"]], 114 | # field: "title", 115 | # level: "error" 116 | # } 117 | # ], 118 | # ... 119 | # ] 120 | 121 | I18n.t(*policy.items.first) 122 | # => "translation missing: en.tram-policy.article/readiness_policy.blank_title" 123 | ``` 124 | 125 | You can change the root scope if you will (this could be useful in libraries): 126 | 127 | ```ruby 128 | class MyGemPolicy < Tram::Policy 129 | root_scope "mygem", "policies" # inherited by subclasses 130 | end 131 | 132 | class Article::ReadinessPolicy < MyGemPolicy 133 | # ... 134 | end 135 | 136 | # ... 137 | I18n.t(*policy.items.first) 138 | # => "translation missing: en.mygem.policies.article/readiness_policy.blank_title" 139 | ``` 140 | 141 | ### Messages 142 | 143 | Error messages contain translation of `policy.items` in the current locale: 144 | 145 | ```ruby 146 | policy.messages # or policy.errors.messages, or policy.errors.map(&:message) 147 | # => [ 148 | # "translation missing: en.tram-policy.article/readiness_policy.blank_title", 149 | # "translation missing: en.tram-policy.article/readiness_policy.blank_subtitle" 150 | # ] 151 | ``` 152 | 153 | The messages are translated if the keys are symbolic. Strings are treated as already translated: 154 | 155 | ```ruby 156 | class Article::ReadinessPolicy < Tram::Policy 157 | # ... 158 | def title_presence 159 | return unless title.empty? 160 | errors.add "Title is absent", field: "title", level: "error" 161 | end 162 | end 163 | 164 | # ... 165 | policy.messages 166 | # => [ 167 | # "Title is absent", 168 | # "translation missing: en.tram-policy.article/readiness_policy.blank_subtitle" 169 | # ] 170 | ``` 171 | 172 | ## Partial Validation 173 | 174 | You can use tags in checkers -- to add condition for errors to ignore 175 | 176 | ```ruby 177 | policy.valid? { |error| !%w(warning error).include? error.level } # => false 178 | policy.valid? { |error| error.level != "disaster" } # => true 179 | ``` 180 | 181 | Notice the `invalid?` method takes a block with definitions for errors to count (not ignore) 182 | 183 | ```ruby 184 | policy.invalid? { |error| %w(warning error).include? error.level } # => true 185 | policy.invalid? { |error| error.level == "disaster" } # => false 186 | 187 | policy.validate! { |error| error.level != "disaster" } # => nil (seems ok) 188 | ``` 189 | 190 | ## Composition of Policies 191 | 192 | You can use errors in composition of policies: 193 | 194 | ```ruby 195 | class Article::PublicationPolicy < Tram::Policy 196 | param :article 197 | option :selected, proc { |value| !!value } # enforce booleans 198 | 199 | validate :article_readiness 200 | validate :article_selection 201 | 202 | private 203 | 204 | def article_readiness 205 | # Collects errors tagged by level: "error" from "nested" policy 206 | readiness_errors = Article::ReadinessPolicy[article].errors.filter(level: "error") 207 | 208 | # Merges collected errors to the current ones. 209 | # New errors are also tagged by source: "readiness". 210 | errors.merge(readiness_errors, source: "readiness") 211 | end 212 | 213 | def article_selection 214 | errors.add "Not selected", field: "selected", level: "info" unless selected 215 | end 216 | end 217 | ``` 218 | 219 | ## Exceptions 220 | 221 | 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). 222 | 223 | 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: 224 | 225 | ```ruby 226 | begin 227 | policy.validate! 228 | rescue Tram::Policy::ValidationError => error 229 | error.policy == policy # => true 230 | end 231 | ``` 232 | 233 | ## Additional options 234 | 235 | Class method `.validate` supports several options: 236 | 237 | ### `stop_on_failure` 238 | 239 | If a selected validation will fail (adds an error to the collection), the following validations won't be executed. 240 | 241 | ```ruby 242 | require "tram-policy" 243 | 244 | class Article::ReadinessPolicy < Tram::Policy 245 | # required param for article to validate 246 | param :article 247 | 248 | validate :title_presence, stop_on_failure: true 249 | validate :title_valid # not executed if title is absent 250 | 251 | # ... 252 | end 253 | ``` 254 | 255 | ## RSpec matchers 256 | 257 | RSpec matchers defined in a file `tram-policy/matcher` (not loaded in runtime). 258 | 259 | Use `be_invalid_at` matcher to check whether a policy has errors with given tags. 260 | 261 | ```ruby 262 | # app/policies/user/readiness_policy.rb 263 | class User::ReadinessPolicy < Tram::Policy 264 | option :name, proc(&:to_s), optional: true 265 | option :email, proc(&:to_s), optional: true 266 | 267 | validate :name_presence 268 | 269 | private 270 | 271 | def name_presence 272 | return unless name.empty? 273 | errors.add "Name is absent", level: "error" 274 | end 275 | end 276 | ``` 277 | 278 | ```ruby 279 | # spec/spec_helper.rb 280 | require "tram/policy/rspec" 281 | ``` 282 | 283 | ```ruby 284 | # spec/policies/user/readiness_policy_spec.rb 285 | RSpec.describe User::ReadinessPolicy do 286 | subject(:policy) { described_class[email: "user@example.com"] } 287 | 288 | let(:user) { build :user } # <- expected a factory 289 | 290 | it { is_expected.to be_invalid } 291 | it { is_expected.to be_invalid_at level: "error" } 292 | it { is_expected.to be_valid_at level: "info" } 293 | end 294 | ``` 295 | 296 | The 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`). 297 | 298 | ## Generators 299 | 300 | The gem provides simple tool for scaffolding new policy along with its RSpec test template and translations. 301 | 302 | ```shell 303 | $ tram-policy user/readiness_policy -p user -o admin -v name_present:blank_name email_present:blank_email 304 | ``` 305 | 306 | This will generate a policy class with specification compatible to both [RSpec][rspec] and [FactoryBot][factory_bot]. 307 | 308 | Under the keys `-p` and `-o` define params and options of the policy. 309 | Key `-v` should contain validation methods along with their error message keys. 310 | 311 | ## Installation 312 | 313 | Add this line to your application's Gemfile: 314 | 315 | ```ruby 316 | gem 'tram-policy' 317 | ``` 318 | 319 | And then execute: 320 | 321 | ```shell 322 | $ bundle 323 | ``` 324 | 325 | Or install it yourself as: 326 | 327 | ```shell 328 | $ gem install tram-policy 329 | ``` 330 | 331 | ## License 332 | 333 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 334 | 335 | [codeclimate-badger]: https://img.shields.io/codeclimate/github/tram-rb/tram-policy.svg?style=flat 336 | [codeclimate]: https://codeclimate.com/github/tram-rb/tram-policy 337 | [gem-badger]: https://img.shields.io/gem/v/tram-policy.svg?style=flat 338 | [gem]: https://rubygems.org/gems/tram-policy 339 | [inch-badger]: http://inch-ci.org/github/tram-rb/tram-policy.svg 340 | [inch]: https://inch-ci.org/github/tram-rb/tram-policy 341 | [travis-badger]: https://img.shields.io/travis/tram-rb/tram-policy/master.svg?style=flat 342 | [active-model-validation]: http://api.rubyonrails.org/classes/ActiveModel/Validations.html 343 | [active-record-validation]: http://guides.rubyonrails.org/active_record_validations.html 344 | [dry-validation]: http://dry-rb.org/gems/dry-validation/ 345 | [dry-initializer]: http://dry-rb.org/gems/dry-initializer/ 346 | [i18n]: https://github.com/svenfuchs/i18n 347 | [rspec]: http://rspec.info/ 348 | [factory_bot]: https://github.com/thoughtbot/factory_bot 349 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler::GemHelper.install_tasks 3 | 4 | require "rspec/core/rake_task" 5 | RSpec::Core::RakeTask.new :default 6 | 7 | task default: :spec 8 | -------------------------------------------------------------------------------- /bin/tram-policy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.expand_path("../../lib/tram/policy/generator", __FILE__) 3 | 4 | Tram::Policy::Generator.start 5 | -------------------------------------------------------------------------------- /lib/tram-policy.rb: -------------------------------------------------------------------------------- 1 | require_relative "tram/policy" 2 | -------------------------------------------------------------------------------- /lib/tram/policy.rb: -------------------------------------------------------------------------------- 1 | require "dry-initializer" 2 | require "i18n" 3 | 4 | # Top-level scope for Tram collection of gems 5 | module Tram 6 | # Base class for policy objects with composable validation errors 7 | class Policy 8 | require_relative "policy/validation_error" 9 | require_relative "policy/inflector" 10 | require_relative "policy/error" 11 | require_relative "policy/errors" 12 | require_relative "policy/validator" 13 | require_relative "policy/dsl" 14 | 15 | extend Dry::Initializer 16 | extend DSL 17 | 18 | # The scope used for translating error messages 19 | # 20 | # @return [Array] 21 | # 22 | def scope 23 | Array self.class.scope 24 | end 25 | 26 | # @!method t(message, options) 27 | # Translates a message in the scope of current policy 28 | # 29 | # @param [#to_s] message 30 | # @param [Hash] options 31 | # @return [String] 32 | # 33 | def t(message, **options) 34 | return message.to_s unless message.is_a? Symbol 35 | I18n.t message, scope: scope, **options 36 | end 37 | 38 | # The collection of validation errors 39 | # 40 | # @return [Tram::Policy::Errors] 41 | # 42 | def errors 43 | @errors ||= Errors.new(scope: scope) 44 | end 45 | 46 | # The array of error items for lazy translation 47 | # 48 | # @return [Array] 49 | # 50 | def items 51 | errors.items 52 | end 53 | 54 | # The array of error messages translated for the current locale 55 | # 56 | # @return [Array] 57 | # 58 | def messages 59 | errors.messages 60 | end 61 | 62 | # Checks whether the policy is valid 63 | # 64 | # @param [Proc, nil] filter Block describing **errors to be skipped** 65 | # @return [Boolean] 66 | # 67 | def valid?(&filter) 68 | filter ? errors.reject(&filter).empty? : errors.empty? 69 | end 70 | 71 | # Checks whether the policy is invalid 72 | # 73 | # @param [Proc, nil] filter Block describing **the only errors to count** 74 | # @return [Boolean] 75 | # 76 | def invalid?(&filter) 77 | filter ? errors.any?(&filter) : errors.any? 78 | end 79 | 80 | # Raises exception if the policy is not valid 81 | # 82 | # @param (see #valid?) 83 | # @raise [Tram::Policy::ValidationError] if the policy isn't valid 84 | # @return [nil] if the policy is valid 85 | # 86 | def validate!(&filter) 87 | raise ValidationError.new(self, filter) unless valid?(&filter) 88 | end 89 | 90 | # Human-readable representation of the policy 91 | # 92 | # @example Displays policy name and its attributes 93 | # UserPolicy[name: "Andy"].inspect 94 | # # => # "Andy"]> 95 | # 96 | # @return [String] 97 | # 98 | def inspect 99 | "#<#{self.class.name}[#{__attributes__}]>" 100 | end 101 | 102 | private 103 | 104 | def initialize(*, **) 105 | super 106 | 107 | self.class.validators.each do |validator| 108 | size = errors.count 109 | validator.check(self) 110 | break if (errors.count > size) && validator.stop_on_failure 111 | end 112 | end 113 | 114 | def __attributes__ 115 | @__attributes__ ||= self.class.dry_initializer.attributes(self) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/tram/policy/dsl.rb: -------------------------------------------------------------------------------- 1 | class Tram::Policy 2 | # Class-level DSL for policy objects 3 | module DSL 4 | # @!method validate(name, opts) 5 | # Registers a validator 6 | # 7 | # @param [#to_sym, nil] name (nil) 8 | # @option opts [Boolean] :stop_on_failure 9 | # @return [self] 10 | # 11 | def validate(name = nil, **opts, &block) 12 | local_validators << Validator.new(name, block, **opts) 13 | self 14 | end 15 | 16 | # Policy constructor/validator (alias for [.new]) 17 | # 18 | # @param [Object] *args 19 | # @return [Tram::Policy] 20 | # 21 | def [](*args, **kwargs) 22 | new(*args, **kwargs) 23 | end 24 | 25 | # Sets the root scope of the policy and its subclasses 26 | # 27 | # @param [String, Array] value 28 | # @return [self] 29 | # 30 | def root_scope(*value) 31 | tap { @root_scope = value.flatten.map(&:to_s).reject(&:empty?) } 32 | end 33 | 34 | # Translation scope for a policy 35 | # 36 | # @return [Array] 37 | # 38 | def scope 39 | @scope ||= Array(@root_scope) + [Inflector.underscore(name)] 40 | end 41 | 42 | # List of validators defined by a policy per se 43 | # 44 | # @return [Array] 45 | # 46 | def local_validators 47 | @local_validators ||= [] 48 | end 49 | 50 | # List of all applicable validators from both the policy and its parent 51 | # 52 | # @return [Array] 53 | # 54 | def validators 55 | parent_validators = self == Tram::Policy ? [] : superclass.validators 56 | (parent_validators + local_validators).uniq 57 | end 58 | 59 | private 60 | 61 | def inherited(klass) 62 | super 63 | klass.send :instance_variable_set, :@root_scope, @root_scope 64 | end 65 | 66 | def self.extended(klass) 67 | super 68 | klass.send :instance_variable_set, :@root_scope, %w[tram-policy] 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tram/policy/error.rb: -------------------------------------------------------------------------------- 1 | class Tram::Policy 2 | # Validation error with message and assigned tags 3 | # 4 | # Notice: an error is context-independent; it knows nothing about 5 | # a collection it is placed to; it can be safely moved 6 | # from one collection of [Tram::Policy::Errors] to another. 7 | # 8 | class Error 9 | # @!method self.new(value, opts = {}) 10 | # Builds an error 11 | # 12 | # If another error is send to the constructor, the error returned unchanged 13 | # 14 | # @param [Tram::Policy::Error, #to_s] value 15 | # @param [Hash] tags 16 | # @return [Tram::Policy::Error] 17 | # 18 | def self.new(value, **tags) 19 | value.instance_of?(self) ? value : super 20 | end 21 | 22 | # @!attribute [r] key 23 | # @return [Symbol, String] error key 24 | attr_reader :key 25 | 26 | # @!attribute [r] tags 27 | # @return [Hash] error tags 28 | attr_reader :tags 29 | 30 | # List of arguments for [I18n.t] 31 | # 32 | # @return [Array] 33 | # 34 | def item 35 | [key, tags] 36 | end 37 | alias to_a item 38 | 39 | # Text of error message translated to the current locale 40 | # 41 | # @return [String] 42 | # 43 | def message 44 | key.is_a?(Symbol) ? I18n.t(key, **tags) : key.to_s 45 | end 46 | 47 | # Fetches an option 48 | # 49 | # @param [#to_sym] tag 50 | # @return [Object] 51 | # 52 | def [](tag) 53 | tags[tag.to_sym] 54 | end 55 | 56 | # Fetches the tag 57 | # 58 | # @param [#to_sym] tag 59 | # @param [Object] default 60 | # @param [Proc] block 61 | # @return [Object] 62 | # 63 | def fetch(tag, default = UNDEFINED, &block) 64 | if default == UNDEFINED 65 | tags.fetch(tag.to_sym, &block) 66 | else 67 | tags.fetch(tag.to_sym, default, &block) 68 | end 69 | end 70 | 71 | # Compares an error to another object using method [#item] 72 | # 73 | # @param [Object] other Other object to compare to 74 | # @return [Boolean] 75 | # 76 | def ==(other) 77 | other.respond_to?(:to_a) && other.to_a == item 78 | end 79 | 80 | # @!method contain?(some_key = nil, some_tags = {}) 81 | # Checks whether the error contain given key and tags 82 | # 83 | # @param [Object] some_key Expected key of the error 84 | # @param [Hash] some_tags Expected tags of the error 85 | # @return [Boolean] 86 | # 87 | def contain?(some_key = nil, **some_tags) 88 | return false if some_key&.!= key 89 | some_tags.each { |k, v| return false unless tags[k] == v } 90 | true 91 | end 92 | 93 | private 94 | 95 | UNDEFINED = Dry::Initializer::UNDEFINED 96 | DEFAULT_SCOPE = %w[tram-policy errors].freeze 97 | 98 | def initialize(key, **tags) 99 | @key = key 100 | @tags = tags 101 | @tags[:scope] = @tags.fetch(:scope) { DEFAULT_SCOPE } if key.is_a?(Symbol) 102 | end 103 | 104 | def respond_to_missing?(*) 105 | true 106 | end 107 | 108 | def method_missing(name, *args, **kwargs, &block) 109 | args.any? || kwargs.any? || block ? super : tags[name] 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/tram/policy/errors.rb: -------------------------------------------------------------------------------- 1 | class Tram::Policy 2 | # 3 | # Enumerable collection of unique unordered validation errors 4 | # 5 | # Notice: A collection is context-dependent; 6 | # it knows about a scope of policy it belongs to, 7 | # and how to translate error messages in that scope. 8 | # 9 | class Errors 10 | include Enumerable 11 | 12 | # @!attribute [r] scope 13 | # @return [Array] the scope for error messages' translation 14 | attr_reader :scope 15 | 16 | # @!method add(message, tags) 17 | # Adds error message to the collection 18 | # 19 | # @param [#to_s] message Either a message, or a symbolic key for translation 20 | # @param [Hash] tags Tags to be attached to the message 21 | # @return [self] the collection 22 | # 23 | def add(message, **tags) 24 | raise ArgumentError.new("Error message should be defined") unless message 25 | 26 | tap do 27 | tags = { scope: scope }.merge(tags) if message.is_a?(Symbol) 28 | @set << Tram::Policy::Error.new(message, **tags) 29 | end 30 | end 31 | 32 | # Iterates by collected errors 33 | # 34 | # @yeldparam [Tram::Policy::Error] 35 | # @return [Enumerator] 36 | # 37 | def each 38 | @set.each { |error| yield(error) } 39 | end 40 | 41 | # @!method filter(key = nil, tags) 42 | # Filter errors by optional key and tags 43 | # 44 | # @param [#to_s] key The key to filter errors by 45 | # @param [Hash] tags The list of tags to filter errors by 46 | # @return [Tram::Policy::Errors] 47 | # 48 | def filter(key = nil, **tags) 49 | list = each_with_object(Set.new) do |error, obj| 50 | obj << error if error.contain?(key, **tags) 51 | end 52 | self.class.new(scope: scope, errors: list) 53 | end 54 | 55 | # @!method empty? 56 | # Checks whether a collection is empty 57 | # 58 | # @return [Boolean] 59 | # 60 | def empty?(&block) 61 | block ? !any?(&block) : !any? 62 | end 63 | 64 | # The array of error items for translation 65 | # 66 | # @return [Array] 67 | # 68 | def items 69 | @set.map(&:item) 70 | end 71 | 72 | # The array of ordered error messages 73 | # 74 | # @return [Array] 75 | # 76 | def messages 77 | @set.map(&:message).sort 78 | end 79 | 80 | # @!method merge(other, options) 81 | # Merges other collection to the current one and returns new collection 82 | # with the current scope 83 | # 84 | # @param [Tram::Policy::Errors] other Collection to be merged 85 | # @param [Hash] options Options to be added to merged errors 86 | # @yieldparam [Hash] hash of error options 87 | # @return [self] 88 | # 89 | # @example Add some tag to merged errors 90 | # policy.merge(other) { |err| err[:source] = "other" } 91 | # 92 | def merge(other, **options) 93 | return self unless other.is_a?(self.class) 94 | 95 | other.each do |err| 96 | key, opts = err.item 97 | opts = yield(opts) if block_given? 98 | add key, **opts.merge(options) 99 | end 100 | 101 | self 102 | end 103 | 104 | private 105 | 106 | def initialize(**options) 107 | @scope = options[:scope] || Error::DEFAULT_SCOPE 108 | @set = Set.new options[:errors].to_a 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/tram/policy/generator.rb: -------------------------------------------------------------------------------- 1 | require "thor/group" 2 | require "i18n" 3 | 4 | module Tram 5 | class Policy 6 | require_relative "inflector" 7 | 8 | # @private 9 | class Generator < Thor::Group 10 | include Thor::Actions 11 | 12 | desc "Generates new policy class along with its specification" 13 | argument :name, desc: "policy class name", type: :string 14 | class_option :params, desc: "list of policy params", 15 | type: :array, 16 | default: [], 17 | aliases: "-p", 18 | banner: "param[ param]" 19 | class_option :options, desc: "list of policy options", 20 | type: :array, 21 | default: [], 22 | aliases: "-o", 23 | banner: "option[ option]" 24 | class_option :validators, desc: "list of policy validators", 25 | type: :array, 26 | default: [], 27 | aliases: "-v", 28 | banner: "validator[ validator]" 29 | class_option :locales, desc: "list of available_locales", 30 | type: :array, 31 | default: [], 32 | aliases: "-l", 33 | banner: "en[ ru]" 34 | 35 | def self.source_root 36 | File.dirname(__FILE__) 37 | end 38 | 39 | def set_available_locales 40 | @available_locales = \ 41 | if Array(options[:locales]).any? 42 | options[:locales] 43 | else 44 | ask("Enter available locales for translation:").scan(/\w{2}/) 45 | end 46 | end 47 | 48 | def generate_class 49 | template "generator/policy.erb", "app/policies/#{file}.rb" 50 | end 51 | 52 | def generate_locales 53 | available_locales.each do |locale| 54 | @locale = locale 55 | add_locale 56 | localize_policy 57 | parsed_validators.sort_by { |v| v[:key] } 58 | .each { |validator| localize_validator(validator) } 59 | end 60 | end 61 | 62 | def generate_spec 63 | template "generator/policy_spec.erb", "spec/policies/#{file}_spec.rb" 64 | end 65 | 66 | no_tasks do 67 | def available_locales 68 | @available_locales ||= [] 69 | end 70 | 71 | def klass 72 | @klass ||= Inflector.camelize name 73 | end 74 | 75 | def file 76 | @file ||= Inflector.underscore name 77 | end 78 | 79 | def parsed_options 80 | @parsed_options ||= options[:options].map(&:downcase) 81 | end 82 | 83 | def parsed_params 84 | @parsed_params ||= options[:params].map(&:downcase) 85 | end 86 | 87 | def parsed_validators 88 | @parsed_validators ||= options[:validators].map do |str| 89 | name, key = str.downcase.split(":") 90 | { name: name, key: key || name } 91 | end 92 | end 93 | 94 | def policy_signature 95 | @policy_signature ||= ( 96 | parsed_params + \ 97 | parsed_options.map { |option| "#{option}: #{option}" } 98 | ).join(", ") 99 | end 100 | 101 | def locale_file 102 | "config/locales/tram-policy.#{@locale}.yml" 103 | end 104 | 105 | def locale_header 106 | "---\n#{@locale}:\n tram-policy:\n" 107 | end 108 | 109 | def locale_group 110 | @locale_group ||= " #{file}:\n" 111 | end 112 | 113 | def locale_line(key) 114 | " #{key}: translation missing\n" 115 | end 116 | 117 | def add_locale 118 | create_file(locale_file, skip: true) { locale_header } 119 | end 120 | 121 | def localize_policy 122 | append_to_file(locale_file, locale_group) 123 | end 124 | 125 | def localize_validator(key:, **) 126 | insert_into_file locale_file, locale_line(key), after: locale_group 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/tram/policy/generator/policy.erb: -------------------------------------------------------------------------------- 1 | # TODO: describe the policy, its subject and context 2 | class <%= klass %> < Tram::Policy 3 | # TODO: add default values (default: -> { ... }), 4 | # coercers (type: proc(&:to_s)), 5 | # and optional arguments (optional: true) 6 | # when necessary 7 | <% parsed_params.each do |param| -%> 8 | param :<%= param %> 9 | <% end -%> 10 | <% parsed_options.each do |option| -%> 11 | option :<%= option %> 12 | <% end -%> 13 | 14 | <% parsed_validators.each do |validator| -%> 15 | validate :<%= validator[:name] %> 16 | <% end -%> 17 | 18 | private 19 | <% parsed_validators.each do |validator| %> 20 | def <%= validator[:name] %> 21 | # TODO: define a condition 22 | return if true 23 | # TODO: add necessary tags 24 | errors.add :<%= validator[:key] %> 25 | end 26 | <% end -%> 27 | end 28 | -------------------------------------------------------------------------------- /lib/tram/policy/generator/policy_spec.erb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | # TODO: move it to spec_helper 3 | require "tram/policy/rspec" 4 | 5 | RSpec.describe <%= klass %>, ".[]" do 6 | subject(:policy) { described_class[<%= policy_signature %>] } 7 | 8 | <% (parsed_params + parsed_options).each do |name| -%> 9 | let(:<%= name %>) { FactoryGirl.build :<%= name %> } 10 | <% end -%> 11 | 12 | it { is_expected.not_to be_invalid } 13 | <% parsed_validators.each do |v| %> 14 | # TODO: fix context description 15 | context "when <%= "not " if v[:key] == v[:name] %><%= v[:key] %>" do 16 | # TODO: modify some arguments 17 | <% (parsed_params + parsed_options).each do |name| -%> 18 | let(:<%= name %>) { nil } 19 | <% end -%> 20 | # TODO: add necessary tags to focus the condition 21 | it { is_expected.to be_invalid_at } 22 | end 23 | <% end -%> 24 | end 25 | -------------------------------------------------------------------------------- /lib/tram/policy/inflector.rb: -------------------------------------------------------------------------------- 1 | class Tram::Policy 2 | if Object.const_defined? "ActiveSupport::Inflector" 3 | # @private 4 | Inflector = ActiveSupport::Inflector 5 | elsif Object.const_defined? "Inflecto" 6 | # @private 7 | Inflector = ::Inflecto 8 | else 9 | # @private 10 | module Inflector 11 | def self.underscore(name) 12 | name&.dup&.tap do |n| 13 | n.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 14 | n.gsub!(/([a-z\d])([A-Z])/, '\1_\2') 15 | n.gsub!("::", "/") 16 | n.tr!("-", "_") 17 | n.downcase! 18 | end 19 | end 20 | 21 | def self.camelize(name) 22 | name&.dup&.tap do |n| 23 | n.gsub!(/(?:\A|_+)(.)/) { $1.upcase } 24 | n.gsub!(%r{(?:[/|-]+)(.)}) { "::#{$1.upcase}" } 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/tram/policy/rspec.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | 3 | RSpec::Matchers.define :be_invalid_at do |**tags| 4 | def locales 5 | @locales ||= I18n.available_locales 6 | end 7 | 8 | def check(policy, tags) 9 | @errors ||= policy.errors.filter(**tags).map do |error| 10 | { item: error.item }.tap do |obj| 11 | locales.each { |l| obj[l] = I18n.with_locale(l) { error.message } } 12 | end 13 | end 14 | end 15 | 16 | attr_reader :errors 17 | 18 | def missed_translations 19 | @missed_translations ||= \ 20 | errors 21 | .flat_map { |rec| rec.values_at(*locales) } 22 | .select do |message| 23 | message.is_a?(String) && message.start_with?("translation missing") 24 | end 25 | end 26 | 27 | def report_errors 28 | locales.each_with_object("Actual errors:\n") do |loc, text| 29 | text << " #{loc}:\n" 30 | errors.each { |err| text << " - #{err[loc]} #{err[:item]}\n" } 31 | end 32 | end 33 | 34 | match do |policy| 35 | check(policy, tags) 36 | errors.any? && missed_translations.empty? 37 | end 38 | 39 | match_when_negated do |policy| 40 | check(policy, tags) 41 | errors.empty? 42 | end 43 | 44 | failure_message do |policy| 45 | desc = tags.any? ? " with tags: #{tags}" : "" 46 | text = "The policy: #{policy}\n" 47 | text << " should have had errors#{desc}," 48 | text << " whose messages are translated in all available locales.\n" 49 | text << report_errors 50 | text 51 | end 52 | 53 | failure_message_when_negated do |policy| 54 | desc = tags.any? ? " with tags: #{tags}" : "" 55 | text = "#{policy}\nshould not have had any error#{desc}.\n" 56 | text << report_errors 57 | text 58 | end 59 | end 60 | 61 | RSpec::Matchers.define :be_invalid do 62 | match do |policy| 63 | return expect(policy.valid?).to(be_falsey) unless policy.is_a?(Tram::Policy) 64 | expect(policy).to be_invalid_at 65 | end 66 | 67 | match_when_negated do |policy| 68 | return expect(policy.valid?).to(be_truthy) unless policy.is_a?(Tram::Policy) 69 | expect(policy).not_to be_invalid_at 70 | end 71 | end 72 | 73 | RSpec::Matchers.define :be_valid_at do |**tags| 74 | match do |policy| 75 | expect(policy).not_to be_invalid_at(tags) 76 | end 77 | 78 | match_when_negated do |policy| 79 | expect(policy).to be_invalid_at(tags) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/tram/policy/validation_error.rb: -------------------------------------------------------------------------------- 1 | class Tram::Policy 2 | # An exception to be risen by [Tram::Policy#validate!] 3 | class ValidationError < RuntimeError 4 | # Policy object whose validation has caused the exception 5 | # 6 | # @return [Tram::Policy] 7 | # 8 | attr_reader :policy 9 | 10 | private 11 | 12 | def initialize(policy, filter) 13 | @policy = policy 14 | messages = policy.errors.to_a.reject(&filter).map(&:message) 15 | super (["Validation failed with errors:"] + messages).join("\n- ") 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tram/policy/validator.rb: -------------------------------------------------------------------------------- 1 | class Tram::Policy 2 | # @private 3 | class Validator 4 | attr_reader :name, :block, :stop_on_failure 5 | 6 | def ==(other) 7 | other.is_a?(self.class) && name && other.name == name 8 | end 9 | 10 | def check(object) 11 | name ? object.__send__(name) : object.instance_exec(&block) 12 | end 13 | 14 | private 15 | 16 | def initialize(name, block, stop_on_failure: false) 17 | @name = name&.to_sym 18 | @block = to_lambda(block) 19 | raise "Provide either method name or a block" unless !name ^ !block 20 | @stop_on_failure = stop_on_failure 21 | end 22 | 23 | def to_lambda(block) 24 | return unless block 25 | 26 | unbound = Module.new.module_exec do 27 | instance_method define_method(:_, &block) 28 | end 29 | 30 | ->(&b) { unbound.bind(self == block ? block.receiver : self).call(&b) } 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/fixtures/admin_policy.rb: -------------------------------------------------------------------------------- 1 | class Test::AdminPolicy < Test::UserPolicy 2 | validate :login 3 | validate :name 4 | end 5 | -------------------------------------------------------------------------------- /spec/fixtures/customer_policy.rb: -------------------------------------------------------------------------------- 1 | class Test::CustomerPolicy < Tram::Policy 2 | option :name 3 | 4 | validate do 5 | return if name 6 | errors.add :name_presence, field: "name" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/fixtures/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | bad: Something bad has happened 4 | tram-policy: 5 | bad: Something bad has happened 6 | test/customer_policy: 7 | name_presence: Name is absent 8 | test/user_policy: 9 | name_presence: Name is absent -------------------------------------------------------------------------------- /spec/fixtures/user_policy.rb: -------------------------------------------------------------------------------- 1 | class Test::UserPolicy < Tram::Policy 2 | param :user 3 | 4 | validate "name" 5 | validate "email" 6 | validate "name" 7 | 8 | private 9 | 10 | def name 11 | errors.add "No name", level: "warning" unless user.name 12 | end 13 | 14 | def email 15 | user.email 16 | end 17 | 18 | def login 19 | user.login 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "tram/policy" 3 | require "tram/policy/rspec" 4 | require "rspec/its" 5 | 6 | require_relative "support/fixtures_helper.rb" 7 | 8 | RSpec.configure do |config| 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | config.expect_with :rspec do |c| 11 | c.syntax = :expect 12 | end 13 | 14 | config.order = :random 15 | config.filter_run focus: true 16 | config.run_all_when_everything_filtered = true 17 | 18 | config.before(:each) do 19 | Test = Class.new(Module) 20 | I18n.available_locales = %w[en] 21 | I18n.backend.store_translations :en, yaml_fixture_file("en.yml")["en"] 22 | end 23 | 24 | config.after(:each) { Object.send :remove_const, :Test } 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/fixtures_helper.rb: -------------------------------------------------------------------------------- 1 | def fixture_file_path(filename) 2 | File.expand_path "spec/fixtures/#{filename}" 3 | end 4 | 5 | def yaml_fixture_file(filename) 6 | YAML.load_file(fixture_file_path(filename)) 7 | end 8 | 9 | def load_fixture(filename) 10 | load fixture_file_path(filename) 11 | end 12 | -------------------------------------------------------------------------------- /spec/tram/policy/error_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Tram::Policy::Error do 2 | subject(:error) { described_class.new :bad, **options } 3 | 4 | let(:scope) { %w[tram-policy] } 5 | let(:options) { { level: "warning", scope: scope } } 6 | 7 | describe "#item" do 8 | subject { error.item } 9 | it { is_expected.to eq [:bad, level: "warning", scope: scope] } 10 | end 11 | 12 | describe "#message" do 13 | subject { error.message } 14 | it { is_expected.to eq "Something bad has happened" } 15 | end 16 | 17 | describe "#==" do 18 | subject { error == other } 19 | 20 | context "when other object has the same #item:" do 21 | let(:other) { double to_a: error.item } 22 | it { is_expected.to eq true } 23 | end 24 | 25 | context "when other object has different #item:" do 26 | let(:other) { double to_a: [:foo] } 27 | it { is_expected.to eq false } 28 | end 29 | 30 | context "when other object not respond to #item:" do 31 | let(:other) { double } 32 | it { is_expected.to eq false } 33 | end 34 | end 35 | 36 | describe "arbitrary tag" do 37 | subject { error.send tag } 38 | 39 | context "when tag is defined:" do 40 | let(:tag) { "level" } 41 | it { is_expected.to eq "warning" } 42 | end 43 | 44 | context "when tag not defined:" do 45 | let(:tag) { :weight } 46 | it { is_expected.to be_nil } 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/tram/policy/errors_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Tram::Policy::Errors do 2 | let(:scope) { %w[tram-policy] } 3 | let(:errors) { described_class.new(scope: scope) } 4 | 5 | describe ".new" do 6 | subject { errors } 7 | 8 | it { is_expected.to be_kind_of Enumerable } 9 | it { is_expected.to respond_to :empty? } 10 | it { is_expected.to be_empty } 11 | its(:scope) { is_expected.to eql scope } 12 | end 13 | 14 | describe "#add" do 15 | subject { errors.add :omg, level: "info", field: "name" } 16 | 17 | let(:error) { errors.to_a.last } 18 | 19 | it "adds an error to the collection:" do 20 | expect { 2.times { subject } }.to change { errors.count }.by 1 21 | 22 | expect(error).to be_kind_of Tram::Policy::Error 23 | expect(error) 24 | .to eq [:omg, level: "info", field: "name", scope: scope] 25 | end 26 | end 27 | 28 | describe "#empty?" do 29 | subject { errors.add :omg, level: "info", field: "name" } 30 | 31 | it "checks whether error present" do 32 | expect(subject).not_to be_empty 33 | end 34 | 35 | it "accepts a block" do 36 | expect(subject.empty? { |error| error.level != "info" }).to eq true 37 | end 38 | end 39 | 40 | describe "#items" do 41 | subject { errors.items } 42 | 43 | before { errors.add "OMG!", level: "info", field: "name" } 44 | it { is_expected.to eq errors.map(&:item) } 45 | end 46 | 47 | describe "#merge" do 48 | let(:other) { described_class.new(scope: scope) } 49 | 50 | before do 51 | errors.add :"D'OH!", level: "disaster" 52 | other.add "OUCH!", level: "error" 53 | end 54 | 55 | context "without a block:" do 56 | subject { errors.merge(other) } 57 | 58 | it "merges other collection as is" do 59 | expect(subject).to be_a Tram::Policy::Errors 60 | expect(subject.items).to match_array [ 61 | [:"D'OH!", level: "disaster", scope: scope], 62 | ["OUCH!", level: "error"] 63 | ] 64 | end 65 | end 66 | 67 | context "with a block:" do 68 | subject { errors.merge(other) { |err| err.merge(source: "Homer") } } 69 | 70 | it "merges filtered collection as is" do 71 | expect(subject).to be_a Tram::Policy::Errors 72 | expect(subject.items).to match_array [ 73 | [:"D'OH!", level: "disaster", scope: scope], 74 | ["OUCH!", level: "error", source: "Homer"] 75 | ] 76 | end 77 | end 78 | 79 | context "with options:" do 80 | subject { errors.merge(other, source: "Homer") } 81 | 82 | it "merges other collection with given options" do 83 | expect(subject).to be_a Tram::Policy::Errors 84 | expect(subject.items).to match_array [ 85 | [:"D'OH!", level: "disaster", scope: scope], 86 | ["OUCH!", level: "error", source: "Homer"] 87 | ] 88 | end 89 | end 90 | 91 | context "with block and options:" do 92 | subject { errors.merge(other, id: 5) { |err| err.merge id: 3, age: 4 } } 93 | 94 | it "merges filtered collection with given options" do 95 | expect(subject).to be_a Tram::Policy::Errors 96 | expect(subject.items).to match_array [ 97 | [:"D'OH!", level: "disaster", scope: scope], 98 | ["OUCH!", level: "error", id: 5, age: 4] 99 | ] 100 | end 101 | end 102 | 103 | context "with no errors:" do 104 | subject { errors.merge 1 } 105 | it { is_expected.to eql errors } 106 | end 107 | end 108 | 109 | describe "#messages" do 110 | subject { errors.messages } 111 | 112 | before { errors.add "OMG!", level: "info", field: "name" } 113 | it { is_expected.to eq errors.map(&:message) } 114 | end 115 | 116 | describe "#filter" do 117 | before do 118 | errors.add :foo, field: "name", level: "error" 119 | errors.add :foo, field: "email", level: "info" 120 | errors.add :foo, field: "email", level: "error" 121 | end 122 | 123 | context "with filter" do 124 | subject { errors.filter level: "error" } 125 | 126 | it "returns selected errors only" do 127 | expect(subject).to match_array [ 128 | [:foo, field: "name", level: "error", scope: scope], 129 | [:foo, field: "email", level: "error", scope: scope] 130 | ] 131 | end 132 | end 133 | 134 | context "without a filter" do 135 | subject { errors.filter } 136 | 137 | it "returns selected all errors" do 138 | expect(subject).to match_array errors.to_a 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/tram/policy/inflector_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Tram::Policy::Inflector do 2 | let(:snake) { "test/admin2_user_new_policy" } 3 | let(:camel) { "Test::Admin2UserNewPolicy" } 4 | 5 | describe "#underscore" do 6 | subject { described_class.underscore "Test::Admin2USERNew-Policy" } 7 | it { is_expected.to eq snake } 8 | end 9 | 10 | describe "#camelize" do 11 | subject { described_class.camelize snake } 12 | it { is_expected.to eq camel } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/tram/policy/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "RSpec support:" do 2 | subject { Test::CustomerPolicy[name: nil] } 3 | 4 | before do 5 | I18n.available_locales = %i[en] 6 | I18n.backend.store_translations :en, yaml_fixture_file("en.yml")["en"] 7 | load_fixture "customer_policy.rb" 8 | end 9 | 10 | describe "to be_invalid_at" do 11 | it "passes when some translated error present w/o tags constraint" do 12 | expect { expect(subject).to be_invalid_at }.not_to raise_error 13 | end 14 | 15 | it "passes when some translated error present under given tags" do 16 | expect { expect(subject).to be_invalid_at field: "name" } 17 | .not_to raise_error 18 | end 19 | 20 | it "fails when no errors present under given tags" do 21 | expect { expect(subject).to be_invalid_at field: "email" } 22 | .to raise_error RSpec::Expectations::ExpectationNotMetError 23 | end 24 | 25 | it "fails when some translations are absent" do 26 | I18n.available_locales = %i[ru en] 27 | 28 | expect { expect(subject).to be_invalid_at field: "name" } 29 | .to raise_error RSpec::Expectations::ExpectationNotMetError 30 | end 31 | end 32 | 33 | describe "not_to be_invalid_at" do 34 | it "passes when no errors present under given tags" do 35 | expect { expect(subject).not_to be_invalid_at field: "email" } 36 | .not_to raise_error 37 | end 38 | 39 | it "fails when some error present under given tags" do 40 | expect { expect(subject).not_to be_invalid_at field: "name" } 41 | .to raise_error RSpec::Expectations::ExpectationNotMetError 42 | end 43 | 44 | it "fails when some error present w/o tags constraint" do 45 | expect { expect(subject).not_to be_invalid_at } 46 | .to raise_error RSpec::Expectations::ExpectationNotMetError 47 | end 48 | end 49 | 50 | describe "to be_invalid" do 51 | subject { double("model") } 52 | 53 | it "fails with valid non-policy object" do 54 | allow(subject).to receive(:valid?).and_return(true) 55 | expect { expect(subject).to be_invalid } 56 | .to raise_error RSpec::Expectations::ExpectationNotMetError 57 | end 58 | 59 | it "passes with invalid non-policy object" do 60 | allow(subject).to receive(:valid?).and_return(false) 61 | expect { expect(subject).to be_invalid }.not_to raise_error 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/tram/policy/validation_error_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Tram::Policy::ValidationError do 2 | subject(:error) { described_class.new policy, filter } 3 | 4 | let(:one) { double message: "OMG!", level: "error" } 5 | let(:two) { double message: "phew!", level: "warning" } 6 | let(:policy) { double :policy, errors: [one, two] } 7 | 8 | shared_examples :exception_with_messages do |text| 9 | it { is_expected.to be_a RuntimeError } 10 | its(:policy) { is_expected.to eq policy } 11 | its(:message) { is_expected.to eq "Validation failed with errors:#{text}" } 12 | end 13 | 14 | context "with a liberal filter" do 15 | let(:filter) { proc { false } } 16 | it_behaves_like :exception_with_messages, "\n- OMG!\n- phew!" 17 | end 18 | 19 | context "with a restricting filter" do 20 | let(:filter) { proc { |error| error.level != "error" } } 21 | it_behaves_like :exception_with_messages, "\n- OMG!" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/tram/policy_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Tram::Policy do 2 | before do 3 | I18n.available_locales = %w[en] 4 | I18n.backend.store_translations :en, yaml_fixture_file("en.yml")["en"] 5 | 6 | load_fixture "user_policy.rb" 7 | load_fixture "admin_policy.rb" 8 | end 9 | 10 | let(:policy) { Test::UserPolicy[user] } 11 | let(:user) { double :user, name: name, email: nil, login: nil } 12 | let(:name) { nil } 13 | 14 | describe "Dry::Initializer interface" do 15 | it "is accessible" do 16 | expect(described_class.ancestors).to include Dry::Initializer 17 | end 18 | end 19 | 20 | describe ".validate" do 21 | it "defines validators to be called by initializer in proper order" do 22 | expect(user).to receive(:name).ordered 23 | expect(user).to receive(:email).ordered 24 | expect(user).to receive(:name).ordered 25 | 26 | Test::UserPolicy.new(user) 27 | end 28 | 29 | it "preserves order of parent class validators" do 30 | expect(user).to receive(:name).ordered 31 | expect(user).to receive(:email).ordered 32 | expect(user).to receive(:name).ordered 33 | expect(user).to receive(:login).ordered 34 | expect(user).to receive(:name).ordered 35 | 36 | Test::AdminPolicy.new(user) 37 | end 38 | 39 | context "when :stop_on_failure is set" do 40 | before { Test::UserPolicy.validate :name, stop_on_failure: true } 41 | 42 | it "stops validation after failure" do 43 | expect(user).not_to receive(:login) 44 | 45 | Test::AdminPolicy.new(user) 46 | end 47 | 48 | it "continues validation after success" do 49 | user = double :user, name: "Andy", email: nil, login: nil 50 | 51 | expect(user).to receive(:login) 52 | 53 | Test::AdminPolicy.new(user) 54 | end 55 | end 56 | end 57 | 58 | describe "#inspect" do 59 | subject { policy.inspect } 60 | it { is_expected.to eq "##}]>" } 61 | end 62 | 63 | describe "#errors" do 64 | subject { policy.errors } 65 | 66 | its(:class) { is_expected.to eq Tram::Policy::Errors } 67 | its(:scope) { is_expected.to eql policy.scope } 68 | end 69 | 70 | describe "#valid?" do 71 | context "when #errors are present" do 72 | subject { policy.valid? } 73 | let(:name) { nil } 74 | 75 | it { is_expected.to eq false } 76 | end 77 | 78 | context "with a filter" do 79 | subject { policy.valid? { |err| err.level != "error" } } 80 | let(:name) { nil } 81 | 82 | it "takes into account filtered errors" do 83 | expect(subject).to eq true 84 | end 85 | end 86 | 87 | context "when #errors are absent" do 88 | subject { policy.valid? } 89 | let(:name) { :foo } 90 | 91 | it { is_expected.to eq true } 92 | end 93 | end 94 | 95 | describe "#invalid?" do 96 | context "when #errors are present" do 97 | subject { policy.invalid? } 98 | let(:name) { nil } 99 | 100 | it { is_expected.to eq true } 101 | end 102 | 103 | context "with a filter" do 104 | subject { policy.invalid? { |err| err.level == "error" } } 105 | let(:name) { nil } 106 | 107 | it "filters errors out" do 108 | expect(subject).to eq false 109 | end 110 | end 111 | 112 | context "when #errors are absent" do 113 | subject { policy.invalid? } 114 | let(:name) { :foo } 115 | 116 | it { is_expected.to eq false } 117 | end 118 | end 119 | 120 | describe "#validate!" do 121 | context "when #errors are present" do 122 | subject { policy.validate! } 123 | let(:name) { nil } 124 | 125 | it "raises an exception" do 126 | expect { subject }.to raise_error Tram::Policy::ValidationError 127 | end 128 | end 129 | 130 | context "with a filter" do 131 | subject { policy.validate! { |err| err.level != "error" } } 132 | let(:name) { nil } 133 | 134 | it "takes into account filtered errors" do 135 | expect { subject }.not_to raise_error 136 | end 137 | end 138 | 139 | context "when #errors are absent" do 140 | subject { policy.validate! } 141 | let(:name) { :foo } 142 | 143 | it "doesn't raise an exception" do 144 | expect { subject }.not_to raise_error 145 | end 146 | end 147 | end 148 | 149 | describe "#t" do 150 | subject { policy.t(value, level: "error") } 151 | 152 | before do 153 | I18n.backend.store_translations :en, { 154 | "tram-policy" => { 155 | "test/user_policy" => { 156 | "name_presence" => "%{level}: Name is absent" 157 | } 158 | } 159 | } 160 | end 161 | 162 | context "string" do 163 | let(:value) { "Name should be present" } 164 | it { is_expected.to eq value } 165 | end 166 | 167 | context "non-symbol" do 168 | let(:value) { 42 } 169 | it { is_expected.to eq "42" } 170 | end 171 | 172 | context "symbol" do 173 | let(:value) { :name_presence } 174 | it { is_expected.to eq "error: Name is absent" } 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /tram-policy.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |gem| 2 | gem.name = "tram-policy" 3 | gem.version = "2.2.0" 4 | gem.author = ["Viktor Sokolov (gzigzigzeo)", "Andrew Kozin (nepalez)"] 5 | gem.email = "andrew.kozin@gmail.com" 6 | gem.homepage = "https://github.com/tram-rb/tram-policy" 7 | gem.summary = "Policy Object Pattern" 8 | gem.license = "MIT" 9 | 10 | gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 11 | gem.test_files = gem.files.grep(/^spec/) 12 | gem.extra_rdoc_files = Dir["README.md", "LICENSE", "CHANGELOG.md"] 13 | gem.executables = %w[tram-policy] 14 | 15 | gem.required_ruby_version = ">= 2.3" 16 | 17 | gem.add_runtime_dependency "dry-initializer", "> 2", "< 4" 18 | gem.add_runtime_dependency "i18n", "~> 1.0" 19 | 20 | gem.add_development_dependency "rake", "> 10" 21 | gem.add_development_dependency "rspec", "~> 3.0" 22 | gem.add_development_dependency "rspec-its", "~> 1.2" 23 | gem.add_development_dependency "rubocop", "~> 0.49" 24 | gem.add_development_dependency "thor", "~> 0.19" 25 | end 26 | --------------------------------------------------------------------------------