├── .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 |
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 |
--------------------------------------------------------------------------------