├── config └── metrics │ ├── reek.yml │ ├── flay.yml │ ├── saikuro.yml │ ├── cane.yml │ ├── churn.yml │ ├── simplecov.yml │ ├── metric_fu.yml │ ├── roodi.yml │ ├── yardstick.yml │ ├── rubocop.yml │ └── STYLEGUIDE ├── .rspec ├── .coveralls.yml ├── .rubocop.yml ├── .yardopts ├── .gitignore ├── Gemfile ├── lib ├── attestor │ ├── version.rb │ ├── policy │ │ ├── or.rb │ │ ├── and.rb │ │ ├── xor.rb │ │ ├── not.rb │ │ ├── negator.rb │ │ ├── node.rb │ │ └── factory.rb │ ├── validations │ │ ├── delegator.rb │ │ ├── reporter.rb │ │ ├── context.rb │ │ ├── message.rb │ │ ├── validator.rb │ │ └── validators.rb │ ├── invalid_error.rb │ ├── rspec.rb │ ├── report.rb │ ├── policy.rb │ └── validations.rb └── attestor.rb ├── .metrics ├── spec ├── spec_helper.rb ├── tests │ ├── policy │ │ ├── not_spec.rb │ │ ├── and_spec.rb │ │ ├── or_spec.rb │ │ ├── xor_spec.rb │ │ ├── negator_spec.rb │ │ ├── node_spec.rb │ │ └── factory_spec.rb │ ├── validations │ │ ├── delegator_spec.rb │ │ ├── reporter_spec.rb │ │ ├── context_spec.rb │ │ ├── message_spec.rb │ │ ├── validator_spec.rb │ │ └── validators_spec.rb │ ├── invalid_error_spec.rb │ ├── report_spec.rb │ ├── policy_spec.rb │ ├── rspec_spec.rb │ └── validations_spec.rb ├── support │ └── policies.rb └── features │ └── example_spec.rb ├── CHANGELOG.md ├── .travis.yml ├── Guardfile ├── .github └── workflows │ └── main.yml ├── Rakefile ├── attestor.gemspec ├── LICENSE └── README.md /config/metrics/reek.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | --- 2 | service_name: travis-ci 3 | -------------------------------------------------------------------------------- /config/metrics/flay.yml: -------------------------------------------------------------------------------- 1 | --- 2 | minimum_score: 10 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_from: "./config/metrics/rubocop.yml" 3 | -------------------------------------------------------------------------------- /config/metrics/saikuro.yml: -------------------------------------------------------------------------------- 1 | --- 2 | warn_cyclo: 4 3 | error_cyclo: 6 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --asset LICENSE 2 | --exclude lib/attestor/version.rb 3 | --output doc/api 4 | -------------------------------------------------------------------------------- /config/metrics/cane.yml: -------------------------------------------------------------------------------- 1 | --- 2 | abc_max: "10" 3 | line_length: "80" 4 | no_doc: "y" 5 | no_readme: "y" 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.lock 3 | .bundle/ 4 | .yardoc/ 5 | coverage/ 6 | doc/ 7 | log/ 8 | pkg/ 9 | tmp/ 10 | -------------------------------------------------------------------------------- /config/metrics/churn.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignore_files: 3 | - spec 4 | - config 5 | minimum_churn_count: 0 6 | start_date: "1 year ago" 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "hexx-suit", "~> 2.1", group: :metrics if RUBY_ENGINE == "ruby" 6 | -------------------------------------------------------------------------------- /config/metrics/simplecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | output: tmp/coverage 3 | filters: # The list of paths to be excluded from coverage checkup 4 | - "spec/" 5 | - "config/" 6 | groups: [] 7 | -------------------------------------------------------------------------------- /lib/attestor/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | # The semantic version of the module. 6 | # @see http://semver.org/ Semantic versioning 2.0 7 | VERSION = "2.2.1".freeze 8 | 9 | end # module Attestor 10 | -------------------------------------------------------------------------------- /.metrics: -------------------------------------------------------------------------------- 1 | # Settings for metric_fu and its packages are collected in the `config/metrics` 2 | # and loaded by the Hexx::Suit::Metrics::MetricFu. 3 | 4 | begin 5 | require "hexx-suit" 6 | Hexx::Suit::Metrics::MetricFu.load 7 | rescue LoadError 8 | false 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "hexx-rspec" 3 | 4 | begin 5 | require "hexx-suit" 6 | rescue LoadError 7 | false 8 | end 9 | 10 | # Loads runtime metrics 11 | Hexx::RSpec.load_metrics_for(self) 12 | 13 | # Loads the code under test 14 | require "attestor" 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v2.2.1 2015-05-31 2 | 3 | ### Fixed 4 | 5 | * Bug in calling `Attestor::Policy.new` with a block (gregory) 6 | 7 | ### Internal 8 | 9 | * Compatibility to rubies API 1.9.3+ (gregory) 10 | 11 | [Compare v2.2.0...v2.2.1](https://github.com/nepalez/attestor/compare/v2.2.0...v2.2.1) 12 | -------------------------------------------------------------------------------- /config/metrics/metric_fu.yml: -------------------------------------------------------------------------------- 1 | --- 2 | folders: # The list of folders to be used by any metric. 3 | - lib 4 | - app 5 | metrics: # The list of allowed metrics. The other metrics are disabled. 6 | - cane 7 | - churn 8 | - flay 9 | - flog 10 | - reek 11 | - roodi 12 | - saikuro 13 | format: html 14 | output: tmp/metric_fu 15 | verbose: false 16 | -------------------------------------------------------------------------------- /lib/attestor/policy/or.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # @private 8 | class Or < Node 9 | 10 | def validate! 11 | return if detect(&:valid?) 12 | super 13 | end 14 | 15 | end # class Or 16 | 17 | end # module Base 18 | 19 | end # module Policy 20 | -------------------------------------------------------------------------------- /lib/attestor/policy/and.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # @private 8 | class And < Node 9 | 10 | def validate! 11 | return unless detect(&:invalid?) 12 | super 13 | end 14 | 15 | end # class And 16 | 17 | end # module Base 18 | 19 | end # module Policy 20 | -------------------------------------------------------------------------------- /lib/attestor/validations/delegator.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Validations 6 | 7 | # @private 8 | class Delegator < Validator 9 | 10 | def validate!(_) 11 | super.validate! 12 | end 13 | 14 | end # class Follower 15 | 16 | end # module Validations 17 | 18 | end # module Attestor 19 | -------------------------------------------------------------------------------- /lib/attestor/policy/xor.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # @private 8 | class Xor < Node 9 | 10 | def validate! 11 | return if detect(&:valid?) && detect(&:invalid?) 12 | super 13 | end 14 | 15 | end # class Xor 16 | 17 | end # module Base 18 | 19 | end # module Policy 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | sudo: false 4 | cache: bundler 5 | bundler_args: --without metrics 6 | script: rake test:coverage:run 7 | rvm: 8 | - '1.9.3' 9 | - '2.0' 10 | - '2.1' 11 | - '2.2' 12 | - ruby-head 13 | - rbx-2 --1.9 14 | - rbx-2 --2.0 15 | - jruby-9.0.0.0.pre1 16 | - jruby-head 17 | matrix: 18 | allow_failures: 19 | - rvm: ruby-head 20 | - rvm: jruby-head 21 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | guard :rspec, cmd: "bundle exec rspec" do 4 | 5 | watch(%r{^lib/attestor/(.+)\.rb$}) do |m| 6 | "spec/tests/#{ m[1] }_spec.rb" 7 | end 8 | 9 | watch(%r{^spec/tests/.+_spec.rb}) 10 | 11 | watch("lib/*.rb") { "spec" } 12 | watch("spec/spec_helper.rb") { "spec" } 13 | watch("spec/support/**/*.rb") { "spec" } 14 | 15 | end # guard :rspec 16 | -------------------------------------------------------------------------------- /lib/attestor/policy/not.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # @private 8 | class Not < Node 9 | 10 | def initialize(_) 11 | super 12 | end 13 | 14 | def validate! 15 | return unless detect(&:valid?) 16 | super 17 | end 18 | 19 | end # class And 20 | 21 | end # module Base 22 | 23 | end # module Policy 24 | -------------------------------------------------------------------------------- /lib/attestor/validations/reporter.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Validations 6 | 7 | # @private 8 | module Reporter 9 | 10 | def validate(object) 11 | validate! object 12 | Report.new(object) 13 | rescue InvalidError => error 14 | Report.new(error.object, error) 15 | end 16 | 17 | end # module Reporter 18 | 19 | end # module Validations 20 | 21 | end # module Attestor 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Notify sample_app about the update 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | notify-sample-app: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Notify sample_app 12 | uses: peter-evans/repository-dispatch@v1 13 | with: 14 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 15 | repository: nepalez/sample_app 16 | event-type: attestor-updated 17 | client-payload: '{}' -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | begin 3 | require "bundler/setup" 4 | rescue LoadError 5 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 6 | exit 7 | end 8 | 9 | # Loads bundler tasks 10 | Bundler::GemHelper.install_tasks 11 | 12 | # Loads the Hexx::RSpec and its tasks 13 | begin 14 | require "hexx-suit" 15 | Hexx::Suit.install_tasks 16 | rescue LoadError 17 | require "hexx-rspec" 18 | Hexx::RSpec.install_tasks 19 | end 20 | 21 | # Sets the Hexx::RSpec :test task to default 22 | task default: "test:coverage:run" 23 | -------------------------------------------------------------------------------- /lib/attestor/policy/negator.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # @private 8 | class Negator 9 | 10 | def initialize(composer, policy) 11 | @policy = policy 12 | @composer = composer 13 | freeze 14 | end 15 | 16 | def not(*policies) 17 | composer.new policy, policies.flat_map(&Not.method(:new)) 18 | end 19 | 20 | attr_reader :policy, :composer 21 | 22 | end # class Negator 23 | 24 | end # module Policy 25 | 26 | end # module Attestor 27 | -------------------------------------------------------------------------------- /lib/attestor/policy/node.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # @private 8 | class Node 9 | include Attestor::Policy, Enumerable 10 | 11 | attr_reader :branches 12 | 13 | def initialize(*branches) 14 | @branches = branches.flatten 15 | freeze 16 | end 17 | 18 | def validate! 19 | invalid :base 20 | end 21 | 22 | def each 23 | block_given? ? branches.each { |item| yield(item.validate) } : to_enum 24 | end 25 | 26 | end # class Node 27 | 28 | end # module Base 29 | 30 | end # module Policy 31 | -------------------------------------------------------------------------------- /lib/attestor/validations/context.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Validations 6 | 7 | # @private 8 | class Context 9 | 10 | attr_accessor :klass, :options 11 | 12 | def initialize(klass, options) 13 | self.klass = klass 14 | self.options = options 15 | end 16 | 17 | def validate(name = nil, &block) 18 | klass.validate(name, options, &block) 19 | end 20 | 21 | def validates(name = nil, &block) 22 | klass.validates(name, options, &block) 23 | end 24 | 25 | end # class Context 26 | 27 | end # module Validations 28 | 29 | end # module Attestor 30 | -------------------------------------------------------------------------------- /lib/attestor/invalid_error.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | # The exception to be raised when an unsafe validation fails 6 | class InvalidError < RuntimeError 7 | 8 | # @private 9 | def initialize(object, messages = nil) 10 | @object = object 11 | @messages = Array(messages) 12 | freeze 13 | end 14 | 15 | # @!attribute [r] object 16 | # The invalid object 17 | # 18 | # @return [Object] 19 | attr_reader :object 20 | 21 | # @!attribute [r] messages 22 | # The list of validation error messages 23 | # 24 | # @return [Array] 25 | attr_reader :messages 26 | 27 | end # class InvalidError 28 | 29 | end # module Attestor 30 | -------------------------------------------------------------------------------- /config/metrics/roodi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AssignmentInConditionalCheck: 3 | CaseMissingElseCheck: 4 | ClassLineCountCheck: 5 | line_count: 300 6 | ClassNameCheck: 7 | pattern: !ruby/regexp /^[A-Z][a-zA-Z0-9]*$/ 8 | ClassVariableCheck: 9 | CyclomaticComplexityBlockCheck: 10 | complexity: 4 11 | CyclomaticComplexityMethodCheck: 12 | complexity: 8 13 | EmptyRescueBodyCheck: 14 | ForLoopCheck: 15 | MethodLineCountCheck: 16 | line_count: 20 17 | MethodNameCheck: 18 | pattern: !ruby/regexp /^[\||\^|\&|\!]$|^[_a-z<>=\[|+-\/\*`]+[_a-z0-9_<>=~@\[\]]*[=!\?]?$/ 19 | ModuleLineCountCheck: 20 | line_count: 300 21 | ModuleNameCheck: 22 | pattern: !ruby/regexp /^[A-Z][a-zA-Z0-9]*$/ 23 | ParameterNumberCheck: 24 | parameter_count: 5 25 | -------------------------------------------------------------------------------- /config/metrics/yardstick.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Settings added by the 'hexx-suit' gem 3 | output: "tmp/yardstick/output.log" 4 | path: "lib/**/*.rb" 5 | rules: 6 | ApiTag::Presence: 7 | enabled: true 8 | exclude: [] 9 | ApiTag::Inclusion: 10 | enabled: true 11 | exclude: [] 12 | ApiTag::ProtectedMethod: 13 | enabled: true 14 | exclude: [] 15 | ApiTag::PrivateMethod: 16 | enabled: false 17 | exclude: [] 18 | ExampleTag: 19 | enabled: true 20 | exclude: [] 21 | ReturnTag: 22 | enabled: true 23 | exclude: [] 24 | Summary::Presence: 25 | enabled: true 26 | exclude: [] 27 | Summary::Length: 28 | enabled: true 29 | exclude: [] 30 | Summary::Delimiter: 31 | enabled: true 32 | exclude: [] 33 | Summary::SingleLine: 34 | enabled: true 35 | exclude: [] 36 | threshold: 100 37 | verbose: false 38 | -------------------------------------------------------------------------------- /attestor.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "attestor/version" 3 | 4 | Gem::Specification.new do |gem| 5 | 6 | gem.name = "attestor" 7 | gem.version = Attestor::VERSION.dup 8 | gem.author = "Andrew Kozin" 9 | gem.email = "andrew.kozin@gmail.com" 10 | gem.homepage = "https://github.com/nepalez/attestor" 11 | gem.summary = "Validations for immutable Ruby objects" 12 | gem.description = gem.summary 13 | gem.license = "MIT" 14 | 15 | gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 16 | gem.test_files = Dir["spec/**/*.rb"] 17 | gem.extra_rdoc_files = Dir["README.md", "LICENSE"] 18 | gem.require_paths = ["lib"] 19 | 20 | gem.required_ruby_version = "~> 2.0" 21 | gem.add_runtime_dependency "extlib", "~> 0.9" 22 | gem.add_development_dependency "hexx-rspec", "~> 0.4" 23 | 24 | end # Gem::Specification 25 | -------------------------------------------------------------------------------- /spec/tests/policy/not_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # describe #valid_policy and #invalid_policy builders 4 | # also describes shared examples for all policies 5 | require "support/policies" 6 | 7 | describe Attestor::Policy::Not do 8 | 9 | subject { described_class.new item } 10 | 11 | describe ".new" do 12 | 13 | let(:item) { valid_policy } 14 | 15 | it_behaves_like "creating a node" 16 | it_behaves_like "creating an immutable object" 17 | 18 | end # context 19 | 20 | describe "#validate!" do 21 | 22 | context "when a part is invalid" do 23 | 24 | let(:item) { invalid_policy } 25 | 26 | it_behaves_like "passing validation" 27 | 28 | end # context 29 | 30 | context "when a part is valid" do 31 | 32 | let(:item) { valid_policy } 33 | 34 | it_behaves_like "failing validation" 35 | 36 | end # context 37 | 38 | end # describe #validate! 39 | 40 | end # describe Policy::Base::Not 41 | -------------------------------------------------------------------------------- /lib/attestor/validations/message.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Validations 6 | 7 | # @private 8 | class Message < String 9 | 10 | def initialize(value, object, options = {}) 11 | @value = value 12 | @object = object 13 | @options = options 14 | super(@value.instance_of?(Symbol) ? translation : @value.to_s) 15 | freeze 16 | end 17 | 18 | private 19 | 20 | def translation 21 | I18n.t @value, @options.merge(scope: scope, default: default) 22 | end 23 | 24 | def scope 25 | %W(attestor errors #{ class_scope }) 26 | end 27 | 28 | def class_scope 29 | @object.class.to_s.split("::").map(&:snake_case).join("/") 30 | end 31 | 32 | def default 33 | "#{ @object } is invalid (#{ @value })" 34 | end 35 | 36 | end # class Message 37 | 38 | end # module Validations 39 | 40 | end # module Attestor 41 | -------------------------------------------------------------------------------- /lib/attestor/rspec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "rspec" 3 | 4 | module Attestor 5 | 6 | # Helpers for validations 7 | module RSpec 8 | include ::RSpec::Mocks::ExampleMethods 9 | 10 | # Mocks a valid object 11 | # 12 | # @return [RSpec::Mocks::Double] 13 | def valid_spy 14 | object = spy 15 | allow(object).to receive(:validate!) 16 | allow(object).to receive(:validate) { Report.new(object) } 17 | 18 | object 19 | end 20 | 21 | # Mocks an invalid object with given error messages 22 | # 23 | # @param [String, Array] messages 24 | # 25 | # @return [RSpec::Mocks::Double] 26 | def invalid_spy(messages = "invalid") 27 | object = spy 28 | error = InvalidError.new(object, messages) 29 | allow(object).to receive(:validate!) { fail error } 30 | allow(object).to receive(:validate) { Report.new(object, error) } 31 | 32 | object 33 | end 34 | 35 | end # module RSpec 36 | 37 | end # module Attestor 38 | -------------------------------------------------------------------------------- /spec/tests/policy/and_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # describe #valid_policy and #invalid_policy builders 4 | # also describes shared examples for all policies 5 | require "support/policies" 6 | 7 | describe Attestor::Policy::And do 8 | 9 | subject { described_class.new items } 10 | 11 | describe ".new" do 12 | 13 | let(:items) { [valid_policy] } 14 | 15 | it_behaves_like "creating a node" 16 | it_behaves_like "creating an immutable object" 17 | 18 | end # context 19 | 20 | describe "#validate!" do 21 | 22 | context "when all the parts are valid" do 23 | 24 | let(:items) { 3.times.map { valid_policy } } 25 | 26 | it_behaves_like "passing validation" 27 | 28 | end # context 29 | 30 | context "when a part is invalid" do 31 | 32 | let(:items) { [valid_policy, valid_policy, invalid_policy] } 33 | 34 | it_behaves_like "failing validation" 35 | 36 | end # context 37 | 38 | end # describe #validate! 39 | 40 | end # describe Policy::Base::Not 41 | -------------------------------------------------------------------------------- /spec/tests/policy/or_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # describe #valid_policy and #invalid_policy builders 4 | # also describes shared examples for all policies 5 | require "support/policies" 6 | 7 | describe Attestor::Policy::Or do 8 | 9 | subject { described_class.new items } 10 | 11 | describe ".new" do 12 | 13 | let(:items) { [valid_policy] } 14 | 15 | it_behaves_like "creating a node" 16 | it_behaves_like "creating an immutable object" 17 | 18 | end # context 19 | 20 | describe "#validate!" do 21 | 22 | context "when valid part exists" do 23 | 24 | let(:items) { [valid_policy, valid_policy, invalid_policy] } 25 | 26 | it_behaves_like "passing validation" 27 | 28 | end # context 29 | 30 | context "when all parts are invalid" do 31 | 32 | let(:items) { 3.times.map { invalid_policy } } 33 | 34 | it_behaves_like "failing validation" 35 | 36 | end # context 37 | 38 | end # describe #validate! 39 | 40 | end # describe Policy::Base::Not 41 | -------------------------------------------------------------------------------- /lib/attestor.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "extlib" 4 | 5 | require_relative "attestor/version" 6 | 7 | require_relative "attestor/invalid_error" 8 | require_relative "attestor/report" 9 | 10 | require_relative "attestor/validations" 11 | require_relative "attestor/validations/reporter" 12 | require_relative "attestor/validations/validator" 13 | require_relative "attestor/validations/delegator" 14 | require_relative "attestor/validations/validators" 15 | require_relative "attestor/validations/message" 16 | require_relative "attestor/validations/context" 17 | 18 | require_relative "attestor/policy/factory" 19 | require_relative "attestor/policy" 20 | require_relative "attestor/policy/node" 21 | require_relative "attestor/policy/and" 22 | require_relative "attestor/policy/or" 23 | require_relative "attestor/policy/xor" 24 | require_relative "attestor/policy/not" 25 | require_relative "attestor/policy/negator" 26 | 27 | # Namespace for the code of the 'attestor' gem 28 | module Attestor 29 | 30 | end # module Attestor 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Andrew Kozin (nepalez), andrew.kozin@gmail.com 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 | -------------------------------------------------------------------------------- /lib/attestor/report.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | # Describes the result, returned by safe validation 6 | class Report 7 | 8 | # @private 9 | def initialize(object, error = nil) 10 | @object = object 11 | @error = error 12 | freeze 13 | end 14 | 15 | # @!attribute [r] object 16 | # The object being validated 17 | # 18 | # @return [Object] 19 | attr_reader :object 20 | 21 | # @!attribute [r] error 22 | # The exception raised by validation 23 | # 24 | # @return [Attestor::InvalidError] if validation fails 25 | # @return [nil] if validation passes 26 | attr_reader :error 27 | 28 | # Checks whether validation passes 29 | # 30 | # @return [Boolean] 31 | def valid? 32 | error.blank? 33 | end 34 | 35 | # Checks whether validation fails 36 | # 37 | # @return [Boolean] 38 | def invalid? 39 | !valid? 40 | end 41 | 42 | # Returns the list of error messages 43 | # 44 | # @return [Array] 45 | def messages 46 | error ? error.messages : [] 47 | end 48 | 49 | end # class Report 50 | 51 | end # module Attestor 52 | -------------------------------------------------------------------------------- /spec/support/policies.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # Definitions for testing compound policies 4 | 5 | def valid_policy 6 | double validate!: nil, validate: double(valid?: true, invalid?: false) 7 | end 8 | 9 | def invalid_policy 10 | double validate!: nil, validate: double(valid?: false, invalid?: true) 11 | end 12 | 13 | shared_examples "creating a node" do 14 | 15 | it { is_expected.to be_kind_of Attestor::Policy::Node } 16 | 17 | end # shared examples 18 | 19 | shared_examples "creating an immutable object" do 20 | 21 | it "[freezes a policy]" do 22 | expect(subject).to be_frozen 23 | end 24 | 25 | end # shared examples 26 | 27 | shared_examples "failing validation" do 28 | 29 | it "[raises exception]" do 30 | expect { subject.validate! }.to raise_error Attestor::InvalidError 31 | end 32 | 33 | it "[adds itself to exception]" do 34 | begin 35 | subject.validate! 36 | rescue => error 37 | expect(error.object).to eq subject 38 | end 39 | end 40 | 41 | end # shared examples 42 | 43 | shared_examples "passing validation" do 44 | 45 | it "[raises exception]" do 46 | expect { subject.validate! }.not_to raise_error 47 | end 48 | 49 | end # shared examples 50 | -------------------------------------------------------------------------------- /spec/tests/policy/xor_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # describe #valid_policy and #invalid_policy builders 4 | # also describes shared examples for all policies 5 | require "support/policies" 6 | 7 | describe Attestor::Policy::Xor do 8 | 9 | subject { described_class.new items } 10 | 11 | describe ".new" do 12 | 13 | let(:items) { [valid_policy] } 14 | 15 | it_behaves_like "creating a node" 16 | it_behaves_like "creating an immutable object" 17 | 18 | end # context 19 | 20 | describe "#validate!" do 21 | 22 | context "when both valid and invalid parts exist" do 23 | 24 | let(:items) { [valid_policy, valid_policy, invalid_policy] } 25 | 26 | it_behaves_like "passing validation" 27 | 28 | end # context 29 | 30 | context "when all parts are valid" do 31 | 32 | let(:items) { 3.times.map { valid_policy } } 33 | 34 | it_behaves_like "failing validation" 35 | 36 | end # context 37 | 38 | context "when all parts are invalid" do 39 | 40 | let(:items) { 3.times.map { invalid_policy } } 41 | 42 | it_behaves_like "failing validation" 43 | 44 | end # context 45 | 46 | end # describe #validate! 47 | 48 | end # describe Policy::Base::Not 49 | -------------------------------------------------------------------------------- /spec/tests/validations/delegator_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "support/policies" 4 | 5 | describe Attestor::Validations::Delegator do 6 | 7 | let(:validator) { Attestor::Validations::Validator } 8 | 9 | describe ".new" do 10 | 11 | subject { described_class.new "foo" } 12 | it { is_expected.to be_kind_of validator } 13 | 14 | end # describe .new 15 | 16 | describe "#validate!" do 17 | 18 | let(:object) { double foo: valid_policy } 19 | 20 | context "when initialized without a block" do 21 | 22 | subject { described_class.new "foo" } 23 | after { subject.validate! object } 24 | 25 | it "delegates validation to named method" do 26 | expect(object).to receive_message_chain(:foo, :validate!) 27 | end 28 | 29 | end # context 30 | 31 | context "when initialized with a block" do 32 | 33 | subject { described_class.new { foo } } 34 | after { subject.validate! object } 35 | 36 | it "delegates validation to block" do 37 | expect(object).to receive_message_chain(:foo, :validate!) 38 | end 39 | 40 | end # context 41 | 42 | end # describe #validate! 43 | 44 | end # describe Attestor::Validations::Delegator 45 | -------------------------------------------------------------------------------- /lib/attestor/validations/validator.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Validations 6 | 7 | # @private 8 | class Validator 9 | include Reporter 10 | 11 | def initialize(name = :invalid, except: nil, only: nil, &block) 12 | @name = name.to_sym 13 | @whitelist = normalize(only) 14 | @blacklist = normalize(except) 15 | @block = block 16 | freeze 17 | end 18 | 19 | attr_reader :name, :whitelist, :blacklist, :block 20 | 21 | def used_in_context?(context) 22 | symbol = context.to_sym 23 | whitelisted?(symbol) && !blacklisted?(symbol) 24 | end 25 | 26 | def validate!(object) 27 | block ? object.instance_eval(&block) : object.__send__(name) 28 | end 29 | 30 | private 31 | 32 | def whitelisted?(symbol) 33 | whitelist.empty? || whitelist.include?(symbol) 34 | end 35 | 36 | def blacklisted?(symbol) 37 | blacklist.include? symbol 38 | end 39 | 40 | def normalize(list) 41 | Array(list).map(&:to_sym).uniq 42 | end 43 | 44 | end # class Validator 45 | 46 | end # module Validations 47 | 48 | end # module Attestor 49 | -------------------------------------------------------------------------------- /lib/attestor/validations/validators.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Validations 6 | 7 | # @private 8 | class Validators 9 | include Enumerable 10 | include Reporter 11 | 12 | def initialize(*items) 13 | @items = items.flatten 14 | freeze 15 | end 16 | 17 | def each 18 | block_given? ? items.each { |item| yield(item) } : to_enum 19 | end 20 | 21 | def set(context) 22 | self.class.new select { |item| item.used_in_context? context } 23 | end 24 | 25 | def add_validator(*args, &block) 26 | self.class.new items, Validator.new(*args, &block) 27 | end 28 | 29 | def add_delegator(*args, &block) 30 | self.class.new items, Delegator.new(*args, &block) 31 | end 32 | 33 | def validate!(object) 34 | results = errors(object) 35 | return if results.empty? 36 | fail InvalidError.new object, results.map(&:messages).flatten 37 | end 38 | 39 | private 40 | 41 | attr_reader :items 42 | 43 | def errors(object) 44 | map { |validator| validator.validate(object) }.select(&:invalid?) 45 | end 46 | 47 | end # class Validators 48 | 49 | end # module Validations 50 | 51 | end # module Attestor 52 | -------------------------------------------------------------------------------- /spec/tests/invalid_error_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::InvalidError do 4 | 5 | let(:object) { double :object } 6 | subject { described_class.new object } 7 | 8 | describe ".new" do 9 | 10 | it "creates a RuntimeError" do 11 | expect(subject).to be_kind_of RuntimeError 12 | end 13 | 14 | it "creates immutable object" do 15 | expect(subject).to be_frozen 16 | end 17 | 18 | it "doesn't freeze object" do 19 | subject 20 | expect(object).not_to be_frozen 21 | end 22 | 23 | it "doesn't freeze messages" do 24 | messages = %i(foo bar) 25 | described_class.new object, messages 26 | 27 | expect(messages).not_to be_frozen 28 | end 29 | 30 | end # describe .new 31 | 32 | describe "#object" do 33 | 34 | it "is initialized" do 35 | expect(subject.object).to be_eql object 36 | end 37 | 38 | end # describe #object 39 | 40 | describe "#messages" do 41 | 42 | it "returns an empty array" do 43 | expect(subject.messages).to eq [] 44 | end 45 | 46 | it "can be initialized" do 47 | subject = described_class.new(object, %w(cad cam)) 48 | expect(subject.messages).to eq %w(cad cam) 49 | end 50 | 51 | end # describe #messages 52 | 53 | end # describe Attestor::ValidError 54 | -------------------------------------------------------------------------------- /spec/tests/validations/reporter_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "support/policies" 4 | 5 | describe Attestor::Validations::Reporter do 6 | 7 | let(:object) { double } 8 | let(:error) { Attestor::InvalidError.new object, ["foo"] } 9 | let(:report) { Attestor::Report } 10 | let(:test_class) { Class.new.send(:include, described_class) } 11 | 12 | subject { test_class.new } 13 | 14 | describe "#validate" do 15 | 16 | let(:result) { subject.validate object } 17 | 18 | context "when #validate! fails" do 19 | 20 | before do 21 | allow(subject).to receive(:validate!).with(object) { fail(error) } 22 | end 23 | 24 | it "returns an invalid report" do 25 | expect(result).to be_kind_of report 26 | expect(result.object).to eq object 27 | expect(result.error).not_to be_nil 28 | expect(result.messages).to eq error.messages 29 | end 30 | 31 | end # context 32 | 33 | context "when #validate! passes" do 34 | 35 | before { allow(subject).to receive(:validate!).with(object) } 36 | 37 | it "returns a valid report" do 38 | expect(result).to be_kind_of report 39 | expect(result.object).to eq object 40 | expect(result.error).to be_nil 41 | end 42 | 43 | end # context 44 | 45 | end # describe #validate 46 | 47 | end # describe Attestor::Validations::Reporter 48 | -------------------------------------------------------------------------------- /spec/tests/policy/negator_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Policy::Negator do 4 | 5 | let(:composer) { Attestor::Policy::Node } 6 | let(:not_class) { Attestor::Policy::Not } 7 | let(:policy) { double :policy } 8 | 9 | subject { described_class.new composer, policy } 10 | 11 | describe ".new" do 12 | 13 | it "creates immutable object" do 14 | expect(subject).to be_frozen 15 | end 16 | 17 | end 18 | 19 | describe "#policy" do 20 | 21 | it "is initialized" do 22 | expect(subject.policy).to eq policy 23 | end 24 | 25 | end # describe #policy 26 | 27 | describe "#composer" do 28 | 29 | it "is initialized" do 30 | expect(subject.composer).to eq composer 31 | end 32 | 33 | end # describe #composer 34 | 35 | describe "#not" do 36 | 37 | let(:another) { double :another } 38 | let(:result) { subject.not(another) } 39 | 40 | it "creates a composer object" do 41 | expect(result).to be_kind_of composer 42 | end 43 | 44 | it "sends its policy to the composer" do 45 | expect(result.branches).to include policy 46 | end 47 | 48 | it "sends the negated arguments to the composer" do 49 | negation = double :negation 50 | expect(not_class).to receive(:new).with(another).and_return(negation) 51 | 52 | expect(result.branches).to include negation 53 | end 54 | 55 | end # describe #not 56 | 57 | end # describe Policy::Base::Negator 58 | -------------------------------------------------------------------------------- /config/metrics/rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # settings added by the 'hexx-suit' module 3 | # output: "tmp/rubocop" 4 | # format: "html" 5 | 6 | AllCops: 7 | Exclude: 8 | - '**/db/schema.rb' 9 | 10 | Lint/HandleExceptions: 11 | Exclude: 12 | - '**/*_spec.rb' 13 | 14 | Lint/RescueException: 15 | Exclude: 16 | - '**/*_spec.rb' 17 | 18 | Style/AccessorMethodName: 19 | Exclude: 20 | - '**/*_spec.rb' 21 | 22 | Style/AsciiComments: 23 | Enabled: false 24 | 25 | Style/ClassAndModuleChildren: 26 | Exclude: 27 | - '**/*_spec.rb' 28 | 29 | Style/Documentation: 30 | Exclude: 31 | - '**/version.rb' 32 | - '**/*_spec.rb' 33 | 34 | Style/EmptyLinesAroundBlockBody: 35 | Enabled: false 36 | 37 | Style/EmptyLinesAroundClassBody: 38 | Enabled: false 39 | 40 | Style/EmptyLinesAroundMethodBody: 41 | Enabled: false 42 | 43 | Style/EmptyLinesAroundModuleBody: 44 | Enabled: false 45 | 46 | Style/EmptyLineBetweenDefs: 47 | Enabled: false 48 | 49 | Style/FileName: 50 | Enabled: false 51 | 52 | Style/RaiseArgs: 53 | EnforcedStyle: compact 54 | 55 | Style/RescueModifier: 56 | Exclude: 57 | - '**/*_spec.rb' 58 | 59 | Style/SingleLineMethods: 60 | Exclude: 61 | - '**/*_spec.rb' 62 | 63 | Style/SingleSpaceBeforeFirstArg: 64 | Enabled: false 65 | 66 | Style/SpecialGlobalVars: 67 | Exclude: 68 | - '**/Gemfile' 69 | - '**/*.gemspec' 70 | 71 | Style/StringLiterals: 72 | EnforcedStyle: double_quotes 73 | 74 | Style/StringLiteralsInInterpolation: 75 | EnforcedStyle: double_quotes 76 | 77 | Style/TrivialAccessors: 78 | Exclude: 79 | - '**/*_spec.rb' 80 | -------------------------------------------------------------------------------- /spec/tests/validations/context_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Validations::Context do 4 | 5 | let(:klass) { double validate: nil, validates: nil } 6 | let(:options) { { except: :foo, only: :bar } } 7 | let(:name) { :baz } 8 | let(:block) { proc { :foo } } 9 | subject { described_class.new klass, options } 10 | 11 | describe "#klass" do 12 | 13 | it "is initialized" do 14 | expect(subject.klass).to eq klass 15 | end 16 | 17 | end # describe #klass 18 | 19 | describe "#options" do 20 | 21 | it "is initialized" do 22 | expect(subject.options).to eq options 23 | end 24 | 25 | end # describe #options 26 | 27 | describe "#validate" do 28 | 29 | it "is delegated to klass with name and options" do 30 | expect(klass).to receive(:validate).with(name, **options) 31 | subject.validate name 32 | end 33 | 34 | it "is delegated to klass with a block" do 35 | expect(klass).to receive(:validate) do |*, &b| 36 | expect(b).to eq block 37 | end 38 | subject.validate(&block) 39 | end 40 | 41 | end # describe #validate 42 | 43 | describe "#validates" do 44 | 45 | it "is delegated to klass with name and options" do 46 | expect(klass).to receive(:validates).with(name, **options) 47 | subject.validates name 48 | end 49 | 50 | it "is delegated to klass with a block" do 51 | expect(klass).to receive(:validates) do |*, &b| 52 | expect(b).to eq block 53 | end 54 | subject.validates(&block) 55 | end 56 | 57 | end # describe #validates 58 | 59 | end # describe Attestor::Validations::Context 60 | -------------------------------------------------------------------------------- /spec/tests/policy/node_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Policy::Node do 4 | 5 | let(:policy_module) { Attestor::Policy } 6 | let(:invalid_error) { Attestor::InvalidError } 7 | 8 | describe ".new" do 9 | 10 | it "creates a policy" do 11 | expect(subject).to be_kind_of policy_module 12 | end 13 | 14 | it "creates a collection" do 15 | expect(subject).to be_kind_of Enumerable 16 | end 17 | 18 | it "creates immutable object" do 19 | expect(subject).to be_frozen 20 | end 21 | 22 | end # describe .new 23 | 24 | describe "#branches" do 25 | 26 | let(:branches) { 3.times.map { double } } 27 | 28 | it "are initialized from list" do 29 | subject = described_class.new(*branches) 30 | expect(subject.branches).to match_array branches 31 | end 32 | 33 | it "are initialized from array" do 34 | subject = described_class.new(branches) 35 | expect(subject.branches).to match_array branches 36 | end 37 | 38 | end # describe #branches 39 | 40 | describe "#each" do 41 | 42 | let(:branches) { 3.times.map { |n| double validate: n } } 43 | 44 | it "returns an enumerator" do 45 | expect(subject.each).to be_kind_of Enumerator 46 | end 47 | 48 | it "iterates through branches' validation reports" do 49 | subject = described_class.new(branches) 50 | expect(subject.to_a).to eq [0, 1, 2] 51 | end 52 | 53 | end # each 54 | 55 | describe "#validate!" do 56 | 57 | let(:message) { Attestor::Validations::Message.new :base, subject } 58 | 59 | it "raises InvalidError" do 60 | expect { subject.validate! }.to raise_error invalid_error 61 | end 62 | 63 | it "adds the :invalid message" do 64 | begin 65 | subject.validate! 66 | rescue => error 67 | expect(error.messages).to contain_exactly message 68 | end 69 | end 70 | 71 | end # describe #validate! 72 | 73 | end # describe Attestor::Policy::Node 74 | -------------------------------------------------------------------------------- /spec/tests/validations/message_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Validations::Message do 4 | 5 | let(:test_class) { Class.new } 6 | before { TestModule = Module.new } 7 | before { TestModule::TestObject = test_class } 8 | after { Object.send :remove_const, :TestModule } 9 | 10 | let(:scope) { test_class.new } 11 | 12 | shared_examples "building frozen string" do 13 | 14 | it { is_expected.to be_kind_of String } 15 | it { is_expected.to be_frozen } 16 | 17 | end # shared examples 18 | 19 | describe ".new" do 20 | 21 | context "with a symbolic argument" do 22 | 23 | subject { described_class.new :invalid, scope, foo: "bar" } 24 | 25 | it_behaves_like "building frozen string" 26 | 27 | it "translates the symbol in given scope" do 28 | expect(I18n).to receive(:t) do |text, options| 29 | expect(text).to eq :invalid 30 | expect(options[:scope]) 31 | .to eq %w(attestor errors test_module/test_object) 32 | expect(options[:default]).to eq "#{ scope } is invalid (invalid)" 33 | expect(options[:foo]).to eq "bar" 34 | "" 35 | end 36 | subject 37 | end 38 | 39 | it "returns the translation" do 40 | expect(subject).to eq "#{ scope } is invalid (invalid)" 41 | end 42 | 43 | end # context 44 | 45 | context "with a non-symbolic argument" do 46 | 47 | subject { described_class.new 1, scope } 48 | 49 | it_behaves_like "building frozen string" 50 | 51 | it "creates a stringified argument" do 52 | expect(subject).to eq "1" 53 | end 54 | 55 | end # context 56 | 57 | context "without options" do 58 | 59 | subject { described_class.new :invalid, scope } 60 | 61 | it_behaves_like "building frozen string" 62 | 63 | it "returns the translation" do 64 | expect(subject).to eq "#{ scope } is invalid (invalid)" 65 | end 66 | 67 | end # context 68 | 69 | end # describe .new 70 | 71 | end # Attestor::Validations::Invalid 72 | -------------------------------------------------------------------------------- /spec/tests/report_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Report do 4 | 5 | let(:invalid_error) { Attestor::InvalidError } 6 | 7 | let(:messages) { ["foo"] } 8 | let(:object) { double } 9 | let(:error) { invalid_error.new object, messages } 10 | subject { described_class.new object, error } 11 | 12 | describe ".new" do 13 | 14 | it "creates an immutable object" do 15 | expect(subject).to be_frozen 16 | end 17 | 18 | end # describe .new 19 | 20 | describe "#object" do 21 | 22 | it "is initialized" do 23 | expect(subject.object).to eq object 24 | end 25 | 26 | end # describe #object 27 | 28 | describe "#error" do 29 | 30 | it "is initialized" do 31 | expect(subject.error).to eq error 32 | end 33 | 34 | it "is set to nil by default" do 35 | expect(described_class.new(object).error).to be_nil 36 | end 37 | 38 | end # describe #error 39 | 40 | describe "#valid?" do 41 | 42 | context "when the #error is set" do 43 | 44 | it "returns false" do 45 | expect(subject.valid?).to eq false 46 | end 47 | 48 | end # context 49 | 50 | context "when the #error is not set" do 51 | 52 | subject { described_class.new object } 53 | 54 | it "returns true" do 55 | expect(subject.valid?).to eq true 56 | end 57 | 58 | end # context 59 | 60 | end # describe #valid? 61 | 62 | describe "#invalid?" do 63 | 64 | context "when the #error is set" do 65 | 66 | it "returns true" do 67 | expect(subject.invalid?).to eq true 68 | end 69 | 70 | end # context 71 | 72 | context "when the #error is not set" do 73 | 74 | subject { described_class.new object } 75 | 76 | it "returns false" do 77 | expect(subject.invalid?).to eq false 78 | end 79 | 80 | end # context 81 | 82 | end # describe #invalid? 83 | 84 | describe "#messages" do 85 | 86 | context "when the #error is set" do 87 | 88 | it "returns error's messages" do 89 | expect(subject.messages).to eq messages 90 | end 91 | 92 | end # context 93 | 94 | context "when the #error is not set" do 95 | 96 | subject { described_class.new object } 97 | 98 | it "returns an empty array" do 99 | expect(subject.messages).to eq [] 100 | end 101 | 102 | end # context 103 | 104 | end # describe #messages 105 | 106 | end # describe Attestor::Report 107 | -------------------------------------------------------------------------------- /spec/tests/policy_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Policy do 4 | 5 | let(:factory) { Attestor::Policy::Factory } 6 | let(:validator) { Attestor::Validations } 7 | let(:invalid) { Attestor::InvalidError.new subject, [] } 8 | let(:others) { 2.times { double } } 9 | 10 | let(:test_class) { Class.new.send(:include, described_class) } 11 | before { Test = test_class } 12 | after { Object.send :remove_const, :Test } 13 | subject { test_class.new } 14 | 15 | describe ".included" do 16 | 17 | it "is a factory" do 18 | expect(test_class).to be_kind_of factory 19 | end 20 | 21 | end # describe .included 22 | 23 | describe ".new" do 24 | 25 | subject { described_class.new(:foo) } 26 | 27 | it "builds the struct" do 28 | expect(subject.new(:baz)).to be_kind_of Struct 29 | end 30 | 31 | it "adds given attributes" do 32 | expect(subject.new(:baz).foo).to eq :baz 33 | end 34 | 35 | it "builds the policy" do 36 | expect(subject.new(:baz)).to be_kind_of described_class 37 | end 38 | 39 | it "yields the block in class scope" do 40 | subject = described_class.new(:foo) do 41 | attr_reader :bar 42 | def foobar; end 43 | def self.barfoo; end 44 | end 45 | expect(subject.new(:baz)).to respond_to :bar 46 | expect(subject.new(:baz)).to respond_to :foobar 47 | expect(subject.new(:baz).class).to respond_to :barfoo 48 | end 49 | 50 | end # describe .new 51 | 52 | describe ".included" do 53 | 54 | it "creates a validator" do 55 | expect(subject).to be_kind_of validator 56 | end 57 | 58 | end # describe .new 59 | 60 | describe "#and" do 61 | 62 | it "calls .and class factory method with self" do 63 | expect(test_class).to receive(:and).with(subject, *others) 64 | subject.and(*others) 65 | end 66 | 67 | end # describe #and 68 | 69 | describe "#or" do 70 | 71 | it "calls .or class factory method with self" do 72 | expect(test_class).to receive(:or).with(subject, *others) 73 | subject.or(*others) 74 | end 75 | 76 | end # describe #or 77 | 78 | describe "#xor" do 79 | 80 | it "calls .xor class factory method with self" do 81 | expect(test_class).to receive(:xor).with(subject, *others) 82 | subject.xor(*others) 83 | end 84 | 85 | end # describe #xor 86 | 87 | describe "#not" do 88 | 89 | it "calls .not class factory method with self" do 90 | expect(test_class).to receive(:not).with(subject) 91 | subject.not 92 | end 93 | 94 | end # describe #not 95 | 96 | end # describe Attestor::Policy 97 | -------------------------------------------------------------------------------- /lib/attestor/policy/factory.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | module Policy 6 | 7 | # The collection of factory methods for creating complex policies 8 | module Factory 9 | 10 | # Builds the AND composition of policy with other policies 11 | # 12 | # @param [Attestor::Policy] policy 13 | # 14 | # @overload and(policy, *others) 15 | # Combines a policy with others 16 | # 17 | # @param [Attestor::Policy, Array] others 18 | # 19 | # @return [Attestor::Policy::And] 20 | # 21 | # @overload and(policy) 22 | # Creates a negator object, awaiting fot #not method call 23 | # 24 | # @return [Attestor::Policy::Negator] 25 | def and(policy, *others) 26 | __factory_method__(And, policy, others) 27 | end 28 | 29 | # Builds the OR composition of policy with other policies 30 | # 31 | # @param [Attestor::Policy] policy 32 | # 33 | # @overload or(policy, *others) 34 | # Combines a policy with others 35 | # 36 | # @param [Attestor::Policy, Array] others 37 | # 38 | # @return [Attestor::Policy::Or] 39 | # 40 | # @overload or(policy) 41 | # Creates a negator object, awaiting fot #not method call 42 | # 43 | # @return [Attestor::Policy::Negator] 44 | def or(policy, *others) 45 | __factory_method__(Or, policy, others) 46 | end 47 | 48 | # Builds the XOR composition of policy with other policies 49 | # 50 | # @param [Attestor::Policy] policy 51 | # 52 | # @overload xor(policy, *others) 53 | # Combines a policy with others 54 | # 55 | # @param [Attestor::Policy, Array] others 56 | # 57 | # @return [Attestor::Policy::Xor] 58 | # 59 | # @overload xor(policy) 60 | # Creates a negator object, awaiting fot #not method call 61 | # 62 | # @return [Attestor::Policy::Negator] 63 | def xor(policy, *others) 64 | __factory_method__(Xor, policy, others) 65 | end 66 | 67 | # Builds the negation of given policy 68 | # 69 | # @param [Attestor::Policy] policy 70 | # 71 | # @return [Attestor::Policy::Not] 72 | def not(policy) 73 | Not.new(policy) 74 | end 75 | 76 | private 77 | 78 | def __factory_method__(composer, policy, others) 79 | policies = others.flatten 80 | return composer.new(policy, policies) if policies.any? 81 | Negator.new(composer, policy) 82 | end 83 | 84 | end # module Factory 85 | 86 | end # module Policy 87 | 88 | end # module Attestor 89 | -------------------------------------------------------------------------------- /spec/tests/policy/factory_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Policy::Factory do 4 | 5 | let(:test_class) { Class.new.send :include, described_class } 6 | let(:subject) { test_class.new } 7 | let(:policy) { double :policy } 8 | let(:others) { 2.times.map { double } } 9 | 10 | shared_examples "creating a node" do |composer| 11 | 12 | it "[creates a Node]" do 13 | expect(result).to be_kind_of composer 14 | end 15 | 16 | it "[sets policies]" do 17 | expect(result.branches).to match_array [policy, *others] 18 | end 19 | 20 | end # shared examples 21 | 22 | shared_examples "creating a negator" do |composer| 23 | 24 | it "[creates a Negator]" do 25 | expect(result).to be_kind_of Attestor::Policy::Negator 26 | end 27 | 28 | it "[sets a policy]" do 29 | expect(result.policy).to eq policy 30 | end 31 | 32 | it "[sets a composer]" do 33 | expect(result.composer).to eq composer 34 | end 35 | 36 | end # shared examples 37 | 38 | describe "#and" do 39 | 40 | context "with one argument" do 41 | 42 | let(:result) { subject.and(policy, []) } 43 | it_behaves_like "creating a negator", Attestor::Policy::And 44 | 45 | end # context 46 | 47 | context "with several arguments" do 48 | 49 | let(:result) { subject.and(policy, others) } 50 | it_behaves_like "creating a node", Attestor::Policy::And 51 | 52 | end # context 53 | 54 | end # describe #and 55 | 56 | describe "#or" do 57 | 58 | context "with one argument" do 59 | 60 | let(:result) { subject.or(policy, []) } 61 | it_behaves_like "creating a negator", Attestor::Policy::Or 62 | 63 | end # context 64 | 65 | context "with several arguments" do 66 | 67 | let(:result) { subject.or(policy, others) } 68 | it_behaves_like "creating a node", Attestor::Policy::Or 69 | 70 | end # context 71 | 72 | end # describe #or 73 | 74 | describe "#xor" do 75 | 76 | context "with one argument" do 77 | 78 | let(:result) { subject.xor(policy, []) } 79 | it_behaves_like "creating a negator", Attestor::Policy::Xor 80 | 81 | end # context 82 | 83 | context "with several arguments" do 84 | 85 | let(:result) { subject.xor(policy, others) } 86 | it_behaves_like "creating a node", Attestor::Policy::Xor 87 | 88 | end # context 89 | 90 | end # describe #or 91 | 92 | describe "#not" do 93 | 94 | let(:others) { [] } 95 | let(:result) { subject.not(policy) } 96 | it_behaves_like "creating a node", Attestor::Policy::Not 97 | 98 | end # describe #or 99 | 100 | end # describe Attestor::Policy::Factory 101 | -------------------------------------------------------------------------------- /spec/features/example_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe "Base example" do 4 | 5 | before do 6 | Account = Struct.new(:client, :limit) 7 | Transaction = Struct.new(:account, :sum) 8 | Transfer = Struct.new(:debet, :credit) 9 | 10 | ConsistencyPolicy = Struct.new(:debet, :credit) 11 | LimitPolicy = Struct.new(:transaction) 12 | InternalTransfer = Struct.new(:debet, :credit) 13 | 14 | class ConsistencyPolicy 15 | include Attestor::Policy 16 | 17 | validate :consistent 18 | 19 | private 20 | 21 | def consistent 22 | return if debet.sum + credit.sum == 0 23 | invalid :inconsistent 24 | end 25 | end 26 | 27 | class LimitPolicy 28 | include Attestor::Policy 29 | 30 | validate :limited 31 | 32 | private 33 | 34 | def limited 35 | return unless (transaction.account.limit + transaction.sum) < 0 36 | invalid :over_the_limit 37 | end 38 | end 39 | 40 | class InternalTransfer 41 | include Attestor::Policy 42 | 43 | validate :internal 44 | 45 | private 46 | 47 | def internal 48 | return if debet.account.client == credit.account.client 49 | invalid :external 50 | end 51 | end 52 | 53 | class Transfer 54 | include Attestor::Validations 55 | 56 | validates { ConsistencyPolicy.new(debet, credit) } 57 | validates :limited, except: :blocked 58 | validate only: :blocked do 59 | internal.validate! 60 | end 61 | 62 | private 63 | 64 | def limited 65 | LimitPolicy.new(debet).or internal 66 | end 67 | 68 | def internal 69 | InternalTransfer.new(debet, credit) 70 | end 71 | end 72 | end 73 | 74 | let(:alice) { Account.new("Alice", 100) } 75 | let(:bob) { Account.new("Bob", 100) } 76 | 77 | let(:a_to_a) do 78 | Transfer.new Transaction.new(alice, -150), Transaction.new(alice, 150) 79 | end 80 | 81 | let(:a_to_b) do 82 | Transfer.new Transaction.new(alice, -150), Transaction.new(bob, 150) 83 | end 84 | 85 | let(:b_to_a) do 86 | Transfer.new Transaction.new(bob, -50), Transaction.new(alice, 50) 87 | end 88 | 89 | it "works fine" do 90 | expect { a_to_a.validate! }.not_to raise_error 91 | expect { b_to_a.validate! }.not_to raise_error 92 | 93 | expect { a_to_b.validate! }.to raise_error Attestor::InvalidError 94 | expect { b_to_a.validate! :blocked }.to raise_error Attestor::InvalidError 95 | end 96 | 97 | after do 98 | %w( 99 | Transfer 100 | InternalTransfer 101 | LimitPolicy 102 | ConsistencyPolicy 103 | Transaction 104 | Account 105 | ).each { |klass| Object.send :remove_const, klass } 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/tests/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "attestor/rspec" 3 | 4 | describe Attestor::RSpec do 5 | 6 | let(:report_class) { Attestor::Report } 7 | let(:invalid_error) { Attestor::InvalidError } 8 | let(:double_class) { ::RSpec::Mocks::Double } 9 | 10 | subject { Class.new.send(:include, described_class).new } 11 | 12 | describe "#valid_spy" do 13 | 14 | let(:valid_spy) { subject.valid_spy } 15 | 16 | it "returns a spy" do 17 | expect(valid_spy).to be_kind_of(double_class) 18 | end 19 | 20 | it "doesn't raise on #validate!" do 21 | expect { valid_spy.validate! }.not_to raise_error 22 | expect(valid_spy.validate!).to be_nil 23 | end 24 | 25 | it "returns a valid report on #validate" do 26 | report = valid_spy.validate 27 | 28 | expect(report).to be_kind_of(report_class) 29 | expect(report).to be_valid 30 | expect(report.object).to eq valid_spy 31 | end 32 | 33 | end # describe #valid_spy 34 | 35 | describe "#invalid_spy" do 36 | 37 | context "without messages" do 38 | 39 | let(:invalid_spy) { subject.invalid_spy } 40 | 41 | it "returns a spy" do 42 | expect(invalid_spy).to be_kind_of(double_class) 43 | end 44 | 45 | it "raises InvalidError on #validate!" do 46 | expect { invalid_spy.validate! }.to raise_error(invalid_error) 47 | end 48 | 49 | it "adds itself to the exception on #validate!" do 50 | begin 51 | invalid_spy.validate! 52 | rescue => error 53 | expect(error.object).to eq invalid_spy 54 | end 55 | end 56 | 57 | it "returns a valid report on #validate" do 58 | report = invalid_spy.validate 59 | 60 | expect(report).to be_kind_of(report_class) 61 | expect(report).to be_invalid 62 | expect(report.object).to eq invalid_spy 63 | end 64 | 65 | it "adds an 'invalid' message to the exception" do 66 | report = invalid_spy.validate 67 | expect(report.messages).to eq ["invalid"] 68 | end 69 | 70 | end # context 71 | 72 | context "with a message" do 73 | 74 | let(:message) { "error" } 75 | let(:invalid_spy) { subject.invalid_spy message } 76 | 77 | it "adds the message to the exception" do 78 | report = invalid_spy.validate 79 | expect(report.messages).to eq [message] 80 | end 81 | 82 | end # context 83 | 84 | context "with a list of messages" do 85 | 86 | let(:messages) { %w(error exception) } 87 | let(:invalid_spy) { subject.invalid_spy(messages) } 88 | 89 | it "adds all the messages to the exception" do 90 | report = invalid_spy.validate 91 | expect(report.messages).to eq messages 92 | end 93 | 94 | end # context 95 | 96 | end # describe #invalid_spy 97 | 98 | end # describe Attestor::RSpec 99 | -------------------------------------------------------------------------------- /lib/attestor/policy.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | # API for policies that validate another objects 6 | module Policy 7 | # @!parse include Attestor::Validations 8 | # @!parse extend Attestor::Validations::ClassMethods 9 | # @!parse extend Attestor::Policy::Factory 10 | 11 | # @private 12 | def self.included(klass) 13 | klass.instance_eval do 14 | include Validations 15 | extend Factory 16 | end 17 | end 18 | 19 | # Builds a policy class with given attributes 20 | # 21 | # @example 22 | # MyPolicy = Attestor::Policy.new(:foo, :bar) do 23 | # attr_reader :baz 24 | # end 25 | # 26 | # @param [Array<#to_sym>] attributes 27 | # @param [Proc] block 28 | # 29 | # @yield the block in the scope of created class 30 | # 31 | # @return [Class] the policy class, based on Struct 32 | def self.new(*attributes, &block) 33 | Struct.new(*attributes) do 34 | include Policy 35 | class_eval(&block) if block_given? 36 | end 37 | end 38 | 39 | # Negates the current policy 40 | # 41 | # @return [Attestor::Policy::Not] 42 | def not 43 | self.class.not(self) 44 | end 45 | 46 | # Builds the AND composition of current policy with other policies 47 | # 48 | # @overload and(policy, *others) 49 | # Combines the policy with the others 50 | # 51 | # The combination is valid if all policies are valid 52 | # 53 | # @param [Attestor::Policy, Array] others 54 | # 55 | # @return [Attestor::Policy::And] 56 | # 57 | # @overload and(policy) 58 | # Creates a negator object, awaiting fot #not method call 59 | # 60 | # @example 61 | # policy.and.not(one, two) 62 | # 63 | # # this is equal to combination with negation of other policies: 64 | # policy.and(one.not, two.not) 65 | # 66 | # @return [Attestor::Policy::Negator] 67 | def and(*others) 68 | self.class.and(self, *others) 69 | end 70 | 71 | # Builds the OR composition of current policy with other policies 72 | # 73 | # @overload or(policy, *others) 74 | # Combines the policy with the others 75 | # 76 | # The combination is valid unless all the policies are invalid 77 | # 78 | # @param [Attestor::Policy, Array] others 79 | # 80 | # @return [Attestor::Policy::And] 81 | # 82 | # @overload or(policy) 83 | # Creates a negator object, awaiting fot #not method call 84 | # 85 | # @example 86 | # policy.or.not(one, two) 87 | # 88 | # # this is equal to combination with negation of other policies: 89 | # policy.or(one.not, two.not) 90 | # 91 | # @return [Attestor::Policy::Negator] 92 | def or(*others) 93 | self.class.or(self, *others) 94 | end 95 | 96 | # Builds the XOR composition of current policy with other policies 97 | # 98 | # @overload xor(policy, *others) 99 | # Combines the policy with the others 100 | # 101 | # The combination is valid if both valid and invalid policies are present 102 | # 103 | # @param [Attestor::Policy, Array] others 104 | # 105 | # @return [Attestor::Policy::And] 106 | # 107 | # @overload xor(policy) 108 | # Creates a negator object, awaiting fot #not method call 109 | # 110 | # @example 111 | # policy.xor.not(one, two) 112 | # 113 | # # this is equal to combination with negation of other policies: 114 | # policy.xor(one.not, two.not) 115 | # 116 | # @return [Attestor::Policy::Negator] 117 | def xor(*others) 118 | self.class.xor(self, *others) 119 | end 120 | 121 | end # module Policy 122 | 123 | end # module Attestor 124 | -------------------------------------------------------------------------------- /spec/tests/validations/validator_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "support/policies" 4 | 5 | describe Attestor::Validations::Validator do 6 | 7 | let(:reporter_module) { Attestor::Validations::Reporter } 8 | subject { described_class.new "foo" } 9 | 10 | describe ".new" do 11 | 12 | it "is immutable" do 13 | expect(subject).to be_frozen 14 | end 15 | 16 | end # describe .new 17 | 18 | describe "#name" do 19 | 20 | it "is initialized as a symbol" do 21 | expect(subject.name).to eq :foo 22 | end 23 | 24 | it "is initialized by default as :invalid" do 25 | expect(described_class.new.name).to eq :invalid 26 | end 27 | 28 | end # describe .name 29 | 30 | describe "#whitelist" do 31 | 32 | it "returns an empty array by default" do 33 | expect(subject.whitelist).to eq [] 34 | end 35 | 36 | it "is initialized" do 37 | subject = described_class.new("foo", only: :bar) 38 | expect(subject.whitelist).to eq [:bar] 39 | end 40 | 41 | it "is symbolized" do 42 | subject = described_class.new("foo", only: %w(bar baz)) 43 | expect(subject.whitelist).to eq [:bar, :baz] 44 | end 45 | 46 | it "contains unique items" do 47 | subject = described_class.new("foo", only: %i(bar bar)) 48 | expect(subject.whitelist).to eq [:bar] 49 | end 50 | 51 | end # describe .whitelist 52 | 53 | describe "#blacklist" do 54 | 55 | it "returns an empty array by default" do 56 | expect(subject.blacklist).to eq [] 57 | end 58 | 59 | it "is initialized" do 60 | subject = described_class.new("foo", except: :bar) 61 | expect(subject.blacklist).to eq [:bar] 62 | end 63 | 64 | it "is symbolized" do 65 | subject = described_class.new("foo", except: %w(bar baz)) 66 | expect(subject.blacklist).to eq [:bar, :baz] 67 | end 68 | 69 | it "contains unique items" do 70 | subject = described_class.new("foo", except: %i(bar bar)) 71 | expect(subject.blacklist).to eq [:bar] 72 | end 73 | 74 | end # describe .blacklist 75 | 76 | describe "#block" do 77 | 78 | it "returns nil by default" do 79 | expect(subject.block).to be_nil 80 | end 81 | 82 | it "is initialized" do 83 | block = proc { :foo } 84 | subject = described_class.new "foo", &block 85 | expect(subject.block).to eq block 86 | end 87 | 88 | end # describe .blacklist 89 | 90 | describe "#used_in_context?" do 91 | 92 | context "not restricted" do 93 | 94 | it { is_expected.to be_used_in_context :bar } 95 | it { is_expected.to be_used_in_context "baz" } 96 | 97 | end # context 98 | 99 | context "blacklisted" do 100 | 101 | subject { described_class.new "foo", except: %w(foo bar) } 102 | 103 | it { is_expected.not_to be_used_in_context "foo" } 104 | it { is_expected.not_to be_used_in_context :bar } 105 | it { is_expected.to be_used_in_context "baz" } 106 | 107 | end # context 108 | 109 | context "whitelisted" do 110 | 111 | subject { described_class.new "foo", only: %w(foo bar) } 112 | 113 | it { is_expected.to be_used_in_context "foo" } 114 | it { is_expected.to be_used_in_context :bar } 115 | it { is_expected.not_to be_used_in_context "baz" } 116 | 117 | end # context 118 | 119 | context "white- and blacklisted" do 120 | 121 | subject do 122 | described_class.new "foo", only: %w(foo bar), except: %w(bar baz) 123 | end 124 | 125 | it { is_expected.to be_used_in_context "foo" } 126 | it { is_expected.not_to be_used_in_context :bar } 127 | it { is_expected.not_to be_used_in_context "baz" } 128 | 129 | end 130 | 131 | end # describe #name 132 | 133 | describe "#validate!" do 134 | 135 | let(:object) { Class.new { private; def foo; end }.new } 136 | after { subject.validate! object } 137 | 138 | context "when no block initialized" do 139 | 140 | subject { described_class.new :foo } 141 | 142 | it "calls validation method" do 143 | expect(object).to receive :foo 144 | end 145 | 146 | end # context 147 | 148 | context "when block initialized" do 149 | 150 | subject { described_class.new(:baz) { foo } } 151 | 152 | it "calls a block" do 153 | expect(object).to receive :foo 154 | end 155 | 156 | end # context 157 | 158 | end # describe #validate! 159 | 160 | describe "#validate" do 161 | 162 | it "is is imported from the Reporter" do 163 | expect(described_class).to include reporter_module 164 | end 165 | 166 | end # describe #validate 167 | 168 | end # describe Attestor::Validation 169 | -------------------------------------------------------------------------------- /lib/attestor/validations.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Attestor 4 | 5 | # API for objects to be validated 6 | module Validations 7 | 8 | # Calls all validators for given context 9 | # 10 | # @param [#to_sym] context 11 | # 12 | # @raise [Attestor::Validations::InvalidError] if validators fail 13 | # @raise [NoMethodError] if some of validators are not implemented 14 | # 15 | # @return [undefined] 16 | def validate!(context = :all) 17 | self.class.validators.set(context).validate! self 18 | end 19 | 20 | # Calls all validators for given context and return validation results 21 | # 22 | # @param (see #validate!) 23 | # 24 | # @return [undefined] 25 | def validate(context = :all) 26 | self.class.validators.set(context).validate self 27 | end 28 | 29 | # Raises InvalidError with a corresponding message 30 | # 31 | # @overload invalid(name, options = {}) 32 | # 33 | # @param [Symbol] name 34 | # the name of the error 35 | # @param [Hash] options 36 | # the options for symbolic name translation 37 | # 38 | # @return [String] 39 | # translation of symbolic name in the current object's scope 40 | # 41 | # @overload invalid(name) 42 | # 43 | # @param [#to_s] name 44 | # the error message (not a symbol) 45 | # 46 | # @return [String] 47 | # the name converted to string 48 | def invalid(name, options = {}) 49 | message = Message.new(name, self, options) 50 | fail InvalidError.new self, message 51 | end 52 | 53 | # @private 54 | module ClassMethods 55 | 56 | # Returns a collection of applied validators 57 | # 58 | # @return [Attestor::Validators] 59 | # 60 | # @api private 61 | def validators 62 | @validators ||= Validators.new 63 | end 64 | 65 | # @!method validate(name = nil, except: nil, only: nil, &block) 66 | # Uses an instance method or block for validation 67 | # 68 | # Mutates the class by changing its {#validators} attribute! 69 | # 70 | # @option options [#to_sym, Array<#to_sym>] :except 71 | # the black list of contexts for validation 72 | # @option options [#to_sym, Array<#to_sym>] :only 73 | # the white list of contexts for validation 74 | # 75 | # @overload validate(name, except: nil, only: nil) 76 | # Uses the instance method for validation 77 | # 78 | # @param [#to_sym] name The name of the instance method 79 | # 80 | # @overload validate(except: nil, only: nil, &block) 81 | # Uses the block (in the scope of the instance) for validation 82 | # 83 | # @param [Proc] block 84 | # 85 | # @return [Attestor::Validators] the updated list of validators 86 | def validate(*args, &block) 87 | @validators = validators.add_validator(*args, &block) 88 | end 89 | 90 | # @!method validates(name = nil, except: nil, only: nil, &block) 91 | # Delegates a validation to instance method or block 92 | # 93 | # Mutates the class by changing its {#validators} attribute! 94 | # 95 | # @option (see #validate) 96 | # 97 | # @overload validates(name, except: nil, only: nil) 98 | # Delegates a validation to instance method 99 | # 100 | # @param [#to_sym] name 101 | # The name of the instance method that should respond to #validate 102 | # 103 | # @overload validates(except: nil, only: nil, &block) 104 | # Uses the block (in the scope of the instance) for validation 105 | # 106 | # @param [Proc] block 107 | # The block that should respond to #validate 108 | # 109 | # @return (see #validate) 110 | def validates(*args, &block) 111 | @validators = validators.add_delegator(*args, &block) 112 | end 113 | 114 | # Groups validations assigned to shared context 115 | # 116 | # @example 117 | # validations only: :foo do 118 | # validates :bar 119 | # validate { invalid :foo unless baz } 120 | # end 121 | # 122 | # # this is equal to: 123 | # 124 | # validates :bar, only: :foo 125 | # validate { invalid :foo unless baz }, only: :foo 126 | # 127 | # @param [Hash] options 128 | # @param [Proc] block 129 | # 130 | # @return [undefined] 131 | def validations(*options, &block) 132 | Context.new(self, Hash[*options]).instance_eval(&block) if block_given? 133 | end 134 | 135 | end # module ClassMethods 136 | 137 | # @private 138 | def self.included(klass) 139 | klass.instance_eval { extend ClassMethods } 140 | end 141 | 142 | # @!parse extend Attestor::Validations::ClassMethods 143 | 144 | end # module Validations 145 | 146 | end # module Attestor 147 | -------------------------------------------------------------------------------- /spec/tests/validations/validators_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | describe Attestor::Validations::Validators do 4 | 5 | let(:validator_class) { Attestor::Validations::Validator } 6 | let(:delegator_class) { Attestor::Validations::Delegator } 7 | let(:reporter_module) { Attestor::Validations::Reporter } 8 | let(:invalid_error) { Attestor::InvalidError } 9 | 10 | describe ".new" do 11 | 12 | it "creates a collection" do 13 | expect(subject).to be_kind_of Enumerable 14 | end 15 | 16 | it "creates an immutable object" do 17 | expect(subject).to be_frozen 18 | end 19 | 20 | end # .new 21 | 22 | describe "#each" do 23 | 24 | it "returns an enumerator" do 25 | expect(subject.each).to be_kind_of Enumerator 26 | end 27 | 28 | it "iterates trough validators" do 29 | validators = %w(foo bar foo).map(&validator_class.method(:new)) 30 | subject = described_class.new validators 31 | 32 | expect(subject.to_a).to match_array validators 33 | end 34 | 35 | end # describe #each 36 | 37 | describe "#add_validator" do 38 | 39 | context "with a name" do 40 | 41 | let(:result) { subject.add_validator "foo" } 42 | 43 | it "returns validators" do 44 | expect(result).to be_kind_of described_class 45 | end 46 | 47 | it "adds validator (not a delegator)" do 48 | item = result.first 49 | expect(item).to be_kind_of validator_class 50 | expect(item).not_to be_kind_of delegator_class 51 | end 52 | 53 | it "assigns a name" do 54 | item = result.first 55 | expect(item.name).to eq :foo 56 | end 57 | 58 | it "preserves existing items" do 59 | expect(result.add_validator(:bar).count).to be 2 60 | end 61 | 62 | end # context 63 | 64 | context "with a block" do 65 | 66 | let(:block) { proc { foo } } 67 | let(:result) { subject.add_validator(&block) } 68 | 69 | it "returns validators" do 70 | expect(result).to be_kind_of described_class 71 | end 72 | 73 | it "adds a validator (not a delegator)" do 74 | item = result.first 75 | expect(item).to be_kind_of validator_class 76 | expect(item).not_to be_kind_of delegator_class 77 | end 78 | 79 | it "assigns a block to the validator" do 80 | item = result.first 81 | expect(item.block).to eq block 82 | end 83 | 84 | end # context 85 | 86 | context "with contexts" do 87 | 88 | let(:result) { subject.add_validator "foo", only: [:foo] } 89 | 90 | it "adds item to validators" do 91 | expect(result.map(&:name)).to eq [:foo] 92 | expect(result.set(:foo).map(&:name)).to eq [:foo] 93 | expect(result.set(:all).map(&:name)).to eq [] 94 | end 95 | 96 | end # context 97 | 98 | end # describe #add_validator 99 | 100 | describe "#add_delegator" do 101 | 102 | context "with a name" do 103 | 104 | let(:result) { subject.add_delegator "foo" } 105 | 106 | it "returns validators" do 107 | expect(result).to be_kind_of described_class 108 | end 109 | 110 | it "adds a delegator" do 111 | item = result.first 112 | expect(item).to be_kind_of delegator_class 113 | end 114 | 115 | it "assigns a name" do 116 | item = result.first 117 | expect(item.name).to eq :foo 118 | end 119 | 120 | it "preserves existing items" do 121 | expect(result.add_delegator(:bar).count).to eq 2 122 | end 123 | 124 | end # context 125 | 126 | context "with a block" do 127 | 128 | let(:block) { proc { foo } } 129 | let(:result) { subject.add_delegator(&block) } 130 | 131 | it "returns validators" do 132 | expect(result).to be_kind_of described_class 133 | end 134 | 135 | it "adds a delegator" do 136 | item = result.first 137 | expect(item).to be_kind_of delegator_class 138 | end 139 | 140 | it "assigns a block to the delegator" do 141 | item = result.first 142 | expect(item.block).to eq block 143 | end 144 | 145 | end # context 146 | 147 | context "with contexts" do 148 | 149 | let(:result) { subject.add_delegator "foo", only: [:foo] } 150 | 151 | it "adds item to validators" do 152 | expect(result.map(&:name)).to eq [:foo] 153 | expect(result.set(:foo).map(&:name)).to eq [:foo] 154 | expect(result.set(:all).map(&:name)).to eq [] 155 | end 156 | 157 | end # context 158 | 159 | end # describe #add_delegator 160 | 161 | describe "#set" do 162 | 163 | subject do 164 | described_class.new 165 | .add_validator("foo") 166 | .add_validator("bar", except: %w(cad)) 167 | .add_validator("baz", except: %w(cam)) 168 | end 169 | 170 | it "returns a collection" do 171 | expect(subject.set "all").to be_kind_of described_class 172 | end 173 | 174 | it "returns a set of items used in given context" do 175 | expect(subject.set("cad").map(&:name)).to contain_exactly :foo, :baz 176 | expect(subject.set("cam").map(&:name)).to contain_exactly :foo, :bar 177 | expect(subject.set("all").map(&:name)).to contain_exactly :foo, :bar, :baz 178 | end 179 | 180 | end # describe #set 181 | 182 | describe "#validate!" do 183 | 184 | let(:object) { double foo: nil, bar: nil } 185 | 186 | subject do 187 | described_class.new 188 | .add_validator("foo") 189 | .add_validator("bar") 190 | end 191 | 192 | context "when all validators passes" do 193 | 194 | it "calls all validators" do 195 | expect(object).to receive :foo 196 | expect(object).to receive :bar 197 | subject.validate! object 198 | end 199 | 200 | it "passes" do 201 | expect { subject.validate! object }.not_to raise_error 202 | end 203 | 204 | end # context 205 | 206 | context "when any validator fails" do 207 | 208 | let(:messages) { %w(foo) } 209 | before do 210 | allow(object) 211 | .to receive(:foo) { fail invalid_error.new(object, messages) } 212 | end 213 | 214 | it "calls all validators" do 215 | expect(object).to receive :foo 216 | expect(object).to receive :bar 217 | subject.validate! object rescue nil 218 | end 219 | 220 | it "fails" do 221 | expect { subject.validate! object }.to raise_error(invalid_error) 222 | end 223 | 224 | it "collects errors from validators" do 225 | begin 226 | subject.validate! object 227 | rescue => error 228 | expect(error.object).to eq object 229 | expect(error.messages).to eq messages 230 | end 231 | end 232 | 233 | end # context 234 | 235 | end # describe #validate! 236 | 237 | describe "#validate" do 238 | 239 | it "is is imported from the Reporter" do 240 | expect(described_class).to include reporter_module 241 | end 242 | 243 | end # describe #validate 244 | 245 | end # describe Attestor::Validators 246 | -------------------------------------------------------------------------------- /config/metrics/STYLEGUIDE: -------------------------------------------------------------------------------- 1 | = Ruby Style Guide 2 | 3 | Adapted from Dan Kubb's Ruby Style Guide 4 | https://github.com/dkubb/styleguide/blob/master/RUBY-STYLE 5 | 6 | == Commiting: 7 | 8 | * Write descriptive commit messages, following the pattern: 9 | 10 | [TYPE] name 11 | 12 | The message, describing the changes being made 13 | 14 | * Use the types below to mark commits: 15 | 16 | - FEATURE - for adding new features, or backward-compatible changes; 17 | - CHANGE - for backward-incompatible changes; 18 | - BUG FIX - for fixing bugs; 19 | - REFACTORING - for other changes of the code not affecting the API; 20 | - OTHER - for changes in documentaton, metrics etc, not touching the code; 21 | - VERSION - for version changes. 22 | 23 | * Always separate commits of different types (such as FEATURE and CHANGE). 24 | 25 | * Try to separate various features from each other. 26 | 27 | * Include specification to the same commit as the code. 28 | 29 | * Run all tests before making a commit. 30 | Never commit the code that break unit tests. 31 | 32 | * Use metric (run `rake check`) before making a commit. 33 | 34 | * Do refactoring before making a commit. Best writing is rewriting. 35 | 36 | * Follow semantic versioning. 37 | 38 | http://semver.org/ 39 | 40 | * For versions name the commit after a version number, following the pattern: 41 | 42 | VERSION 1.0.0-rc2 43 | 44 | 45 | == Formatting: 46 | 47 | * Use UTF-8. Declare encoding in the first line of every file. 48 | 49 | # encoding: utf-8 50 | 51 | * Use 2 space indent, no tabs. 52 | 53 | * Use Unix-style line endings. 54 | 55 | * Use spaces around operators, after commas, colons and semicolons, 56 | around { and before }. 57 | 58 | * No spaces after (, [ and before ], ). 59 | 60 | * Align `when` and `else` with `case`. 61 | 62 | * Use an empty line before the return value of a method (unless it 63 | only has one line), and an empty line between defs. 64 | 65 | * Use empty lines to break up a long method into logical paragraphs. 66 | 67 | * Keep lines fewer than 80 characters. 68 | 69 | * Strip trailing whitespace. 70 | 71 | 72 | == Syntax: 73 | 74 | * Write for 2.0. 75 | 76 | * Use double quotes 77 | 78 | http://viget.com/extend/just-use-double-quoted-ruby-strings 79 | 80 | * Use def with parentheses when there are arguments. 81 | 82 | * Never use for, unless you exactly know why. 83 | 84 | * Never use then, except in case statements. 85 | 86 | * Use when x then ... for one-line cases. 87 | 88 | * Use &&/|| for boolean expressions, and/or for control flow. (Rule 89 | of thumb: If you have to use outer parentheses, you are using the 90 | wrong operators.) 91 | 92 | * Avoid double negation (!!), unless Null Objects are expected. 93 | 94 | http://devblog.avdi.org/2011/05/30/null-objects-and-falsiness 95 | 96 | * Avoid multiline ?:, use if. 97 | 98 | * Use {...} when defining blocks on one line. Use do...end for multiline 99 | blocks. 100 | 101 | * Avoid return where not required. 102 | 103 | * Use ||= freely. 104 | 105 | * Use OO regexps, and avoid =~ $0-9, $~, $` and $' when possible. 106 | 107 | * Do not use Enumerable#inject when the "memo" object does not change between 108 | iterations, use Enumerable#each_with_object instead (in ruby 1.9, 109 | active_support and backports). 110 | 111 | * Prefer ENV.fetch to ENV[] syntax. 112 | Prefer block syntax for ENV.fetch to usage of the second argument. 113 | 114 | 115 | == Naming: 116 | 117 | * Use snake_case for methods. 118 | 119 | * Use CamelCase for classes and modules. (Keep acronyms like HTTP, 120 | RFC, XML uppercase.) 121 | 122 | * Use SCREAMING_SNAKE_CASE for other constants. 123 | 124 | * Do not use single letter variable names. Avoid uncommunicative names. 125 | 126 | * Use consistent variable names. Try to keep the variable names close 127 | to the object class name. 128 | 129 | * Use names prefixed with _ for unused variables. 130 | 131 | * When defining a predicate method that compares against another object of 132 | a similar type, name the argument "other". 133 | 134 | * Prefer map over collect, detect over find, select over find_all. 135 | 136 | * Use def self.method to define singleton methods. 137 | 138 | * Avoid alias when alias_method will do. 139 | 140 | 141 | == Comments: 142 | 143 | * Use YARD and its conventions for API documentation. Don't put an 144 | empty line between the comment block and the def. 145 | 146 | * Comments longer than a word are capitalized and use punctuation. 147 | Use one space after periods. 148 | 149 | * Avoid superfluous comments. 150 | 151 | 152 | == Code structuring: 153 | 154 | * Break code into packages, decoupled from the environment. 155 | 156 | * Wrap packages into gems. 157 | 158 | * Inject dependencies explicitly. 159 | Leave all outer references on the border of any package. Inside 160 | the package use internal references only. 161 | 162 | * Follow SOLID principles. 163 | 164 | http://en.wikipedia.org/wiki/SOLID_(object-oriented_design) 165 | 166 | * Only give a method one purpose for existing. If you pass in a boolean 167 | to a method, what you're saying is that this method has two different 168 | behaviours. Just split it into two single purpose methods. If you have 169 | to use the words "AND" or "OR" to describe what the method does it 170 | probably does too much. 171 | 172 | * Avoid long methods. 173 | Try to keep them at no more than 6 lines long, and preferably 4 or less. 174 | 175 | If sections of a method are logically separate by blank lines, then 176 | that's probably a sign that those sections should be split into separate 177 | methods. 178 | 179 | * Avoid hashes-as-optional-parameters. Does the method do too much? 180 | 181 | * Avoid long parameter lists. 182 | 183 | * Add "global" methods to Kernel (if you have to) and make them private. 184 | 185 | * Use OptionParser for parsing complex command line options and 186 | ruby -s for trivial command line options. 187 | 188 | * Avoid needless metaprogramming. 189 | 190 | * Always freeze objects assigned to constants. 191 | 192 | 193 | == General: 194 | 195 | * Code in a functional way, avoid mutation when it makes sense. 196 | 197 | * Try to have methods either return the state of the object and have 198 | no side effects, or return self and have side effects. This is 199 | otherwise known as Command-query separation (CQS): 200 | 201 | http://en.wikipedia.org/wiki/Command-query_separation 202 | 203 | * Do not mutate arguments unless that is the purpose of the method. 204 | 205 | * Try following TRUE heuristics by Sandi Metz 206 | 207 | http://designisrefactoring.com/2015/02/08/introducing-sandi-metz-true/ 208 | 209 | * Do not mess around in core classes when writing libraries. 210 | Namespace your code inside the modules, or wrap core classes to 211 | decorators of your own. 212 | 213 | * Do not program defensively. 214 | 215 | http://www.erlang.se/doc/programming_rules.shtml#HDR11 216 | 217 | * Keep the code simple. 218 | 219 | * Don't overdesign. 220 | 221 | * Don't underdesign. 222 | 223 | * Avoid bugs. 224 | 225 | * Read other style guides and apply the parts that don't dissent with 226 | this list. 227 | 228 | * Be consistent. 229 | 230 | * Use common sense. -------------------------------------------------------------------------------- /spec/tests/validations_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "support/policies" # for #valid_policy and #invalid_policy definitions 4 | 5 | describe Attestor::Validations do 6 | 7 | let(:validators_class) { Attestor::Validations::Validators } 8 | let(:validator_class) { Attestor::Validations::Validator } 9 | let(:delegator_class) { Attestor::Validations::Delegator } 10 | let(:item_class) { Attestor::Validations::Item } 11 | let(:message_class) { Attestor::Validations::Message } 12 | let(:invalid_error) { Attestor::InvalidError } 13 | 14 | let(:test_class) { Class.new.send(:include, described_class) } 15 | before { Test = test_class } 16 | after { Object.send :remove_const, :Test } 17 | 18 | subject { test_class.new } 19 | 20 | describe ".validators" do 21 | 22 | it "returns Validators" do 23 | expect(test_class.validators).to be_kind_of validators_class 24 | end 25 | 26 | it "is empty by default" do 27 | expect(test_class.validators.to_a).to be_empty 28 | end 29 | 30 | end # describe .validators 31 | 32 | describe ".validate" do 33 | 34 | context "with a name" do 35 | 36 | before { test_class.validate :foo } 37 | 38 | it "registers a validator" do 39 | item = test_class.validators.first 40 | expect(item).to be_kind_of validator_class 41 | expect(item).not_to be_kind_of delegator_class 42 | end 43 | 44 | it "assigns the name to the validator" do 45 | item = test_class.validators.first 46 | expect(item.name).to eq :foo 47 | end 48 | 49 | end # context 50 | 51 | context "with a block" do 52 | 53 | let(:block) { proc { foo } } 54 | before { test_class.validate(&block) } 55 | 56 | it "registers a validator" do 57 | item = test_class.validators.first 58 | expect(item).to be_kind_of validator_class 59 | expect(item).not_to be_kind_of delegator_class 60 | end 61 | 62 | it "assigns the block to the validator" do 63 | item = test_class.validators.first 64 | expect(item.block).to eq block 65 | end 66 | 67 | end # context 68 | 69 | context "with options" do 70 | 71 | before { test_class.validate :foo, only: %w(bar baz), except: "bar" } 72 | 73 | it "uses options" do 74 | expect(test_class.validators.map(&:name)).to eq [:foo] 75 | expect(test_class.validators.set(:baz).map(&:name)).to eq [:foo] 76 | expect(test_class.validators.set(:bar).map(&:name)).to eq [] 77 | end 78 | 79 | end # context 80 | 81 | end # describe .validate 82 | 83 | describe ".validates" do 84 | 85 | context "with a name" do 86 | 87 | before { test_class.validates :foo } 88 | 89 | it "registers a delegator" do 90 | item = test_class.validators.first 91 | expect(item).to be_kind_of delegator_class 92 | end 93 | 94 | it "assigns the name to the delegator" do 95 | item = test_class.validators.first 96 | expect(item.name).to eq :foo 97 | end 98 | 99 | end # context 100 | 101 | context "with a block" do 102 | 103 | let(:block) { proc { foo } } 104 | before { test_class.validates(&block) } 105 | 106 | it "registers a delegator" do 107 | item = test_class.validators.first 108 | expect(item).to be_kind_of delegator_class 109 | end 110 | 111 | it "assigns the block to the delegator" do 112 | item = test_class.validators.first 113 | expect(item.block).to eq block 114 | end 115 | 116 | end # context 117 | 118 | context "with options" do 119 | 120 | before { test_class.validates :foo, only: %w(bar baz), except: "bar" } 121 | 122 | it "uses options" do 123 | expect(test_class.validators.map(&:name)).to eq [:foo] 124 | expect(test_class.validators.set(:baz).map(&:name)).to eq [:foo] 125 | expect(test_class.validators.set(:bar).map(&:name)).to eq [] 126 | end 127 | 128 | end # context 129 | 130 | end # describe .validates 131 | 132 | describe ".validations" do 133 | 134 | let(:options) { { only: :bar, except: :baz } } 135 | let(:context_class) { Attestor::Validations::Context } 136 | let(:context) { double validate: nil, validates: nil } 137 | before { allow(context_class).to receive(:new) { context } } 138 | 139 | context "with a block" do 140 | 141 | after { test_class.validations(options) { validate :foo } } 142 | 143 | it "initializes a context group" do 144 | expect(context_class).to receive(:new).with(test_class, options) 145 | end 146 | 147 | it "calls the block in a context's scope" do 148 | expect(context).to receive(:validate).with(:foo) 149 | end 150 | 151 | end # context 152 | 153 | context "without a block" do 154 | 155 | after { test_class.validations(options) } 156 | 157 | it "does nothing" do 158 | expect(context_class).not_to receive(:new) 159 | end 160 | 161 | end # context 162 | 163 | end # describe .validations 164 | 165 | describe "#invalid" do 166 | 167 | shared_examples "raising an error" do |name, options = {}| 168 | 169 | let(:message) { double } 170 | before do 171 | allow(message_class) 172 | .to receive(:new) 173 | .with(name, subject, options) 174 | .and_return message 175 | end 176 | 177 | it "raises an InvalidError" do 178 | expect { invalid }.to raise_error invalid_error 179 | end 180 | 181 | it "assings itself to the exception" do 182 | begin 183 | invalid 184 | rescue => error 185 | expect(error.object).to eq subject 186 | expect(error.messages).to contain_exactly message 187 | end 188 | end 189 | 190 | end # shared examples 191 | 192 | context "without options" do 193 | 194 | let(:invalid) { subject.invalid :foo } 195 | 196 | it_behaves_like "raising an error", :foo 197 | 198 | end 199 | 200 | context "with options" do 201 | 202 | let(:invalid) { subject.invalid :foo, bar: :baz } 203 | 204 | it_behaves_like "raising an error", :foo, bar: :baz 205 | 206 | end 207 | 208 | end # invalid 209 | 210 | describe "#validate!" do 211 | 212 | before do 213 | test_class.validate :foo 214 | test_class.validate :bar, only: :all 215 | test_class.validates :baz, only: :foo 216 | 217 | allow(subject).to receive(:foo) 218 | allow(subject).to receive(:bar) 219 | allow(subject).to receive(:baz) { valid_policy } 220 | end 221 | 222 | context "without an argument" do 223 | 224 | it "calls validators for :all context" do 225 | expect(subject).to receive(:foo) 226 | expect(subject).to receive(:bar) 227 | expect(subject).not_to receive(:baz) 228 | subject.validate! 229 | end 230 | 231 | end # context 232 | 233 | context ":foo" do 234 | 235 | it "calls validators for :foo context" do 236 | expect(subject).to receive(:foo) 237 | expect(subject).to receive(:baz) 238 | expect(subject).not_to receive(:bar) 239 | subject.validate! :foo 240 | end 241 | 242 | end # context 243 | 244 | end # describe #validate! 245 | 246 | describe "#validate" do 247 | 248 | before do 249 | test_class.validate :foo 250 | test_class.validate :bar, only: :all 251 | test_class.validates :baz, only: :foo 252 | 253 | allow(subject).to receive(:foo) 254 | allow(subject).to receive(:bar) 255 | allow(subject).to receive(:baz) { valid_policy } 256 | end 257 | 258 | context "without an argument" do 259 | 260 | it "calls validators for :all context" do 261 | expect(subject).to receive(:foo) 262 | expect(subject).to receive(:bar) 263 | expect(subject).not_to receive(:baz) 264 | subject.validate 265 | end 266 | 267 | end # context 268 | 269 | context ":foo" do 270 | 271 | it "calls validators for :foo context" do 272 | expect(subject).to receive(:foo) 273 | expect(subject).to receive(:baz) 274 | expect(subject).not_to receive(:bar) 275 | subject.validate :foo 276 | end 277 | 278 | end # context 279 | 280 | end # describe #validate! 281 | 282 | end # describe Attestor::Validations 283 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Attestor 2 | ===== 3 | 4 | Validations and policies for immutable Ruby objects 5 | 6 | **The project is frozen in favor of the [Assertion](https://github.com/nepalez/assertion) gem, that implements even more clean way of setting statements about objects, and validating them.** 7 | 8 | [![Gem Version](https://img.shields.io/gem/v/attestor.svg?style=flat)][gem] 9 | [![Build Status](https://img.shields.io/travis/nepalez/attestor/master.svg?style=flat)][travis] 10 | [![Dependency Status](https://img.shields.io/gemnasium/nepalez/attestor.svg?style=flat)][gemnasium] 11 | [![Code Climate](https://img.shields.io/codeclimate/github/nepalez/attestor.svg?style=flat)][codeclimate] 12 | [![Coverage](https://img.shields.io/coveralls/nepalez/attestor.svg?style=flat)][coveralls] 13 | [![Inline docs](http://inch-ci.org/github/nepalez/attestor.svg)][inch] 14 | 15 | [codeclimate]: https://codeclimate.com/github/nepalez/attestor 16 | [coveralls]: https://coveralls.io/r/nepalez/attestor 17 | [gem]: https://rubygems.org/gems/attestor 18 | [gemnasium]: https://gemnasium.com/nepalez/attestor 19 | [travis]: https://travis-ci.org/nepalez/attestor 20 | [inch]: https://inch-ci.org/github/nepalez/attestor 21 | 22 | Motivation 23 | ---------- 24 | 25 | I like the [ActiveModel::Validations] more than any other part of the whole [Rails]. The more I like it the more painful the problem that **it mutates validated objects**. 26 | 27 | Every time you run validations, the collection of object's `#errors` is cleared and populated with new messages. So you can't validate frozen (immutable) objects without magic tricks. 28 | 29 | To solve the problem, the `attestor` gem: 30 | 31 | * Provides a simplest API for validating immutable objects. 32 | * Makes it possible to isolate validators (as [policy objects]) from their targets. 33 | * Allows policy objects to be composed by logical operations to provide complex policies. 34 | 35 | [ActiveModel::Validations]: http://apidock.com/rails/ActiveModel/Validations 36 | [Rails]: http://rubyonrails.org/ 37 | [policy objects]: http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/ 38 | 39 | Approach 40 | -------- 41 | 42 | Instead of collecting errors inside the object, the module defines two instance methods: 43 | 44 | * `validate!` raises an exception (`Attestor::InvalidError`), that carries errors outside of the object. 45 | * `validate` - the safe version of `validate!`. It rescues from the exception and returns a report object, that carries the exception as well as its error messages. 46 | 47 | In both cases the inspected object stays untouched (and can be made immutable). 48 | 49 | Installation 50 | ------------ 51 | 52 | Add this line to your application's Gemfile: 53 | 54 | ```ruby 55 | # Gemfile 56 | gem "attestor" 57 | ``` 58 | 59 | Then execute: 60 | 61 | ``` 62 | bundle 63 | ``` 64 | 65 | Or add it manually: 66 | 67 | ``` 68 | gem install attestor 69 | ``` 70 | 71 | Base Use 72 | -------- 73 | 74 | Declare validation in the same way as ActiveModel's `.validate` method does: 75 | 76 | ```ruby 77 | Transfer = Struct.new(:debet, :credit) do 78 | include Attestor::Validations 79 | 80 | validate :consistent 81 | 82 | private 83 | 84 | def consistent 85 | fraud = credit.sum - debet.sum 86 | invalid :inconsistent, fraud: fraud if fraud != 0 87 | end 88 | end 89 | ``` 90 | 91 | Alternatively, you can describe validation in a block, executed in an instance's scope: 92 | 93 | ```ruby 94 | class Transfer 95 | # ... 96 | validate { invalid :inconsistent if credit.sum != debet.sum } 97 | end 98 | ``` 99 | 100 | The `#invalid` method translates its argument and raises an exception with the resulting message. 101 | 102 | ```yaml 103 | # config/locales/en.yml 104 | --- 105 | en: 106 | attestor: 107 | errors: 108 | transfer: 109 | inconsistent: "Credit differs from debet by %{fraud}" 110 | ``` 111 | 112 | To validate an object, use its `#validate!` method: 113 | 114 | ```ruby 115 | debet = OpenStruct.new(sum: 100) 116 | credit = OpenStruct.new(sum: 90) 117 | fraud_transfer = Transfer.new(debet, credit) 118 | 119 | begin 120 | transfer.validate! # with the bang 121 | rescue Attestor::InvalidError => error 122 | error.object == transfer # => true 123 | error.messages # => ["Credit differs from debet by 10"] 124 | end 125 | ``` 126 | 127 | Alternatively use the safe version `#validate`. 128 | It rescues from an exception and returns a corresponding report: 129 | 130 | ```ruby 131 | report = transfer.validate # without the bang 132 | 133 | report.valid? # => false 134 | report.invalid? # => true 135 | report.object == transfer # => true 136 | report.messages # => ["Credit differs from debet by 10"] 137 | report.error # => 138 | ``` 139 | 140 | Use of Contexts 141 | --------------- 142 | 143 | Sometimes you need to validate the object agaist the subset of validations, not all of them. 144 | 145 | To do this use `:except` and `:only` options of the `.validate` class method. 146 | 147 | ```ruby 148 | class Transfer 149 | # ... 150 | validate :consistent, except: :steal_of_money 151 | end 152 | ``` 153 | 154 | Then call a `#validate!`/`#validate` methods with that context: 155 | 156 | ```ruby 157 | fraud_transfer.validate! # => InvalidError 158 | fraud_transfer.validate! :steal_of_money # => PASSES! 159 | ``` 160 | 161 | You can use the same validator several times with different contexts. They will be used independently from each other. 162 | 163 | ```ruby 164 | class Transfer 165 | # ... 166 | 167 | validate :consistent, only: :fair_trade, :consistent 168 | validate :consistent, only: :legal 169 | 170 | end 171 | ``` 172 | 173 | You can group validations that uses shared context: 174 | 175 | ```ruby 176 | class Transfer 177 | 178 | # This is the same as: 179 | # 180 | # validate :consistent, only: :fair_trade 181 | # validate :limited, only: :fair_trade 182 | validations only: :fair_trade do 183 | validate :consistent 184 | validate :limited 185 | end 186 | end 187 | ``` 188 | 189 | Delegation 190 | ---------- 191 | 192 | Extract validator to an external object (policy), that responds to `validate!`. 193 | 194 | ```ruby 195 | ConsistentTransfer = Struct.new(:debet, :credit) do 196 | include Attestor::Validations 197 | 198 | def validate! 199 | invalid :inconsistent unless debet.sum == credit.sum 200 | end 201 | end 202 | ``` 203 | 204 | Then use `validates` helper (with an "s" at the end): 205 | 206 | ```ruby 207 | class Transfer 208 | # ... 209 | validates { ConsistentTransfer.new(:debet, :credit) } 210 | end 211 | ``` 212 | 213 | or by method name: 214 | 215 | ```ruby 216 | class Transfer 217 | # ... 218 | validates :consistent_transfer 219 | 220 | def consistent_transfer 221 | ConsistentTransfer.new(:debet, :credit) 222 | end 223 | ``` 224 | 225 | The difference between `.validate :something` and `.validates :something` methods is that: 226 | * `.validate` expects `#something` to make checks and raise error by itself 227 | * `.validates` expects `#something` to respond to `#validate!` 228 | 229 | Policy Objects 230 | -------------- 231 | 232 | Basically the policy includes `Attestor::Validations` with additional methods to allow logical compositions. 233 | 234 | To create a policy as a `Struct` use the builder: 235 | 236 | ```ruby 237 | ConsistencyPolicy = Attestor::Policy.new(:debet, :credit) do 238 | def validate! 239 | fraud = credit - debet 240 | invalid :inconsistent, fraud: fraud if fraud != 0 241 | end 242 | end 243 | ``` 244 | 245 | If you doesn't need `Struct`, include `Attestor::Policy` to the class and initialize its arguments somehow else: 246 | 247 | ```ruby 248 | class ConsistencyPolicy 249 | include Attestor::Policy 250 | # ... 251 | end 252 | ``` 253 | 254 | Policy objects can be used by `validates` method like other objects that respond to `#validate!`: 255 | 256 | ```ruby 257 | class Transfer 258 | # ... 259 | validates { ConsistencyPolicy.new(debet, credit) } 260 | end 261 | ``` 262 | 263 | Complex Policies 264 | ---------------- 265 | 266 | Policies (assertions) can be combined by logical methods. 267 | 268 | Suppose we have two policy objects: 269 | 270 | ```ruby 271 | valid_policy.validate.valid? # => true 272 | invalid_policy.validate.valid? # => false 273 | ``` 274 | 275 | Use factory methods to provide compositions: 276 | 277 | ```ruby 278 | complex_policy = valid_policy.not 279 | complex_policy.validate! # => fails 280 | 281 | complex_policy = valid_policy.and(valid_policy, invalid_policy) 282 | complex_policy.validate! # => fails 283 | 284 | complex_policy = invalid_policy.or(invalid_policy, valid_policy) 285 | complex_policy.validate! # => passes 286 | 287 | complex_policy = valid_policy.xor(valid_poicy, valid_policy) 288 | complex_policy.validate! # => fails 289 | 290 | complex_policy = valid_policy.xor(valid_poicy, invalid_policy) 291 | complex_policy.validate! # => passes 292 | ``` 293 | 294 | The `or`, `and` and `xor` methods called without argument(s) don't provide a policy object. They return lazy composer, expecting `#not` method. 295 | 296 | ```ruby 297 | complex_policy = valid_policy.and.not(invalid_policy, invalid_policy) 298 | # this is the same as: 299 | valid_policy.and(invalid_policy.not, invalid_policy.not) 300 | ``` 301 | 302 | If you prefer wrapping to chaining, use the `Policy` factory methods instead: 303 | 304 | ```ruby 305 | Policy.and(valid_policy, invalid_policy) 306 | # this is the same as: valid_policy.and(invalid_policy) 307 | 308 | Policy.or(valid_policy, invalid_policy) 309 | # this is the same as: valid_policy.or(invalid_policy) 310 | 311 | Policy.xor(valid_policy, invalid_policy) 312 | # this is the same as: valid_policy.xor(invalid_policy) 313 | 314 | Policy.not(valid_policy) 315 | # this is the same as: valid_policy.not 316 | ``` 317 | 318 | As before, you can use any number of policies (except for negation of a single policy) at any number of nesting. 319 | 320 | RSpec helpers 321 | ------------- 322 | 323 | In a RSpec tests you can use spies for valid and invalid objects: 324 | 325 | * `valid_spy` is a spy that returns `nil` in response to `#validate!` and valid report in responce to `#validate`. 326 | * `invalid_spy` raises on `#validate!` and returns invalid report in responce to `#validate` method call. 327 | 328 | ```ruby 329 | require "attestor/rspec" 330 | 331 | describe "something" do 332 | 333 | let(:valid_object) { valid_spy } 334 | let(:invalid_object) { invalid_spy } 335 | 336 | # ... 337 | end 338 | ``` 339 | 340 | To check whether an arbitrary object is valid, simply use `#validate` method's result: 341 | 342 | ```ruby 343 | expect(object.validate).to be_valid 344 | expect(object.validate).to be_invalid 345 | ``` 346 | 347 | Compatibility 348 | ------------- 349 | 350 | Tested under rubies compatible to rubies with API 1.9.3+: 351 | 352 | * MRI 1.9.3+ 353 | * Rubinius-2 (modes 1.9+) 354 | * JRuby 9.0.0.0.pre1+ 355 | 356 | Uses [RSpec] 3.0+ for testing and [hexx-suit] for dev/test tools collection. 357 | 358 | [RSpec]: http://rspec.info 359 | [hexx-suit]: https://github.com/nepalez/hexx-suit 360 | 361 | Contributing 362 | ------------ 363 | 364 | * Read the [STYLEGUIDE](config/metrics/STYLEGUIDE). 365 | * Fork the project 366 | * Create your feature branch (`git checkout -b my-new-feature`) 367 | * Add tests for it 368 | * Commit your changes (`git commit -am '[UPDATE] Add some feature'`) 369 | * Push to the branch (`git push origin my-new-feature`) 370 | * Create a new Pull Request 371 | 372 | Latest Changes 373 | -------------- 374 | 375 | See the [CHANGELOG](CHANGELOG.md) 376 | 377 | License 378 | ------- 379 | 380 | See the [MIT LICENSE](LICENSE). 381 | --------------------------------------------------------------------------------