blood_contracts-ext 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +31 -0
  5. data/.travis.yml +19 -0
  6. data/CHANGELOG.md +23 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +4 -0
  9. data/LICENSE +21 -0
  10. data/README.md +369 -0
  11. data/Rakefile +6 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/blood_contracts-ext.gemspec +27 -0
  15. data/lib/blood_contracts/core/defineable_error.rb +61 -0
  16. data/lib/blood_contracts/core/exception_caught.rb +33 -0
  17. data/lib/blood_contracts/core/exception_handling.rb +36 -0
  18. data/lib/blood_contracts/core/expected_error.rb +16 -0
  19. data/lib/blood_contracts/core/extractable.rb +85 -0
  20. data/lib/blood_contracts/core/map_value.rb +45 -0
  21. data/lib/blood_contracts/core/policy_failure.rb +42 -0
  22. data/lib/blood_contracts/core/sum_policy_failure.rb +9 -0
  23. data/lib/blood_contracts/core/tuple_policy_failure.rb +39 -0
  24. data/lib/blood_contracts/ext/pipe.rb +27 -0
  25. data/lib/blood_contracts/ext/refined.rb +59 -0
  26. data/lib/blood_contracts/ext/sum.rb +29 -0
  27. data/lib/blood_contracts/ext/tuple.rb +28 -0
  28. data/lib/blood_contracts/ext.rb +28 -0
  29. data/spec/blood_contracts/ext/exception_caught_spec.rb +50 -0
  30. data/spec/blood_contracts/ext/expected_error_spec.rb +56 -0
  31. data/spec/blood_contracts/ext/map_value_spec.rb +54 -0
  32. data/spec/blood_contracts/ext/policy_failure_spec.rb +151 -0
  33. data/spec/blood_contracts/ext/policy_spec.rb +138 -0
  34. data/spec/fixtures/en.yml +19 -0
  35. data/spec/spec_helper.rb +24 -0
  36. data/spec/support/fixtures_helper.rb +11 -0
  37. metadata +202 -0
@@ -0,0 +1,61 @@
1
+ module BloodContracts::Core
2
+ # Meta class to define local errors in form of Tram::Policy::Errors
3
+ module DefineableError
4
+ # Concern with the helper to define custom Tram::Policy::Errors
5
+ module Concern
6
+ # @private
7
+ def inherited(other)
8
+ super
9
+ other.instance_variable_set(:@policy_scope, @policy_scope)
10
+ end
11
+
12
+ # Method that turns message into Tram::Policy::Errors object
13
+ #
14
+ # @param message [String, Symbol] (or translations key) for your
15
+ # custom error
16
+ # @option tags [Hash] additional context for translations
17
+ # @option sub_scope [Symbol] is a customizable path to your
18
+ # translation
19
+ # @return [Tram::Policy::Error]
20
+ #
21
+ def define_error(message, tags: {}, sub_scope: nil)
22
+ errors = Tram::Policy::Errors.new(scope: @policy_scope)
23
+ sub_scope = underscore(sub_scope || name)
24
+ message = [sub_scope, message].join(".").to_sym if message.is_a?(Symbol)
25
+ errors.add message, **tags
26
+ errors
27
+ end
28
+
29
+ # @private
30
+ private def underscore(string)
31
+ string.gsub(/([A-Z]+)([A-Z])/, '\1_\2')
32
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
33
+ .gsub("__", "/")
34
+ .gsub("::", "/")
35
+ .gsub(/\s+/, "") # spaces are bad form
36
+ .gsub(/[?%*:|"<>.]+/, "") # reserved characters
37
+ .downcase
38
+ end
39
+ end
40
+
41
+ class << self
42
+ # Method that creates meta class for defining custom Tram::Policy::Errors
43
+ #
44
+ # @param policy_scope [Symbol] is a root for your I18n translations
45
+ # @return [Module]
46
+ #
47
+ def new(policy_scope)
48
+ m = Module.new do
49
+ def self.extended(other)
50
+ other.instance_variable_set(
51
+ :@policy_scope, instance_variable_get(:@policy_scope)
52
+ )
53
+ end
54
+ end
55
+ m.include(Concern)
56
+ m.instance_variable_set(:@policy_scope, policy_scope)
57
+ m
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,33 @@
1
+ module BloodContracts::Core
2
+ # Refinement type which holds exception as a value
3
+ class ExceptionCaught < ContractFailure
4
+ # Constructs refinement type around exception
5
+ #
6
+ # @param value [Exception] value which is wrapped inside the type
7
+ # @option context [Hash] shared context of types matching pipeline
8
+ #
9
+ def initialize(value = nil, context: Hash.new { |h, k| h[k] = {} }, **)
10
+ @errors = []
11
+ @context = context
12
+ @value = value
13
+ @context[:exception] = value
14
+ end
15
+
16
+ # Predicate, whether the data is valid or not
17
+ # (for the ExceptionCaught it is always False)
18
+ #
19
+ # @return [Boolean]
20
+ #
21
+ def valid?
22
+ false
23
+ end
24
+
25
+ # Reader for the exception caught
26
+ #
27
+ # @return [Exception]
28
+ #
29
+ def exception
30
+ @context[:exception]
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ module BloodContracts::Core
2
+ # Concern to wrap matching process with exception handling
3
+ #
4
+ # @example Defines a type with automatic exception handling in form of types
5
+ # class JsonType < ::BC::Refined
6
+ # prepend ExceptionHandling
7
+ #
8
+ # def match
9
+ # @context[:parsed_json] = JSON.parse(value)
10
+ # self
11
+ # end
12
+ # end
13
+ #
14
+ module ExceptionHandling
15
+ # Runs the matching process and returns an ExceptionCaught if
16
+ # StandardError happened inside match call
17
+ #
18
+ # @return [Refined]
19
+ #
20
+ def match
21
+ super
22
+ rescue StandardError => ex
23
+ exception(ex)
24
+ end
25
+
26
+ # Wraps the exception in refinement type
27
+ #
28
+ # @param exc [Exception] raised exception
29
+ # @option context [Hash] shared context of matching pipeline
30
+ # @return [ExceptionCaught]
31
+ #
32
+ def exception(exc, context: @context)
33
+ ExceptionCaught.new(exc, context: context)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ module BloodContracts::Core
2
+ # Custom refinement type that converts the extracted data into
3
+ # Tram::Policy::Errors, could by used when the case is an error but
4
+ # you know how to deal with it inside application
5
+ class ExpectedError < Ext::Refined
6
+ # Generates an Tram::Policy::Errors message using the matching context
7
+ #
8
+ # @return [Tram::Policy::Errors]
9
+ def mapped
10
+ keys = self.class.extractors.keys
11
+ tags = Hash[keys.zip(@context.values_at(*keys))]
12
+ tags = @context if tags.empty?
13
+ self.class.define_error(:message, tags: tags)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,85 @@
1
+ module BloodContracts::Core
2
+ # Concern that turns your refinement type into a coercer with validation
3
+ # delegated to Tram::Policy
4
+ module Extractable
5
+ # @private
6
+ def self.included(other_class)
7
+ other_class.extend(ClassMethods)
8
+ end
9
+
10
+ # DSL definition
11
+ module ClassMethods
12
+ # Configuration about how to extract data from the value, just
13
+ # list of context keys and methods
14
+ attr_reader :extractors
15
+
16
+ # Tram::Policy ancestor that will be used for validation
17
+ #
18
+ # @param [Class]
19
+ #
20
+ attr_accessor :policy
21
+
22
+ # @private
23
+ def inherited(child)
24
+ super
25
+ child.instance_variable_set(:@extractors, {})
26
+ end
27
+
28
+ # DSL to define which method to use to extract data from the value
29
+ #
30
+ # @param extractor_name [Symbol] key to store the extracted data in the
31
+ # context
32
+ # @option method_name [Symbol] custom method name to use for extraction
33
+ # @return [Nothing]
34
+ #
35
+ def extract(extractor_name, method_name: extractor_name)
36
+ extractors[extractor_name] = [method_name]
37
+ end
38
+ end
39
+
40
+ # Turns matching process into 2 steps:
41
+ # - extraction of data from the value
42
+ # - validation using the policy_klass
43
+ #
44
+ # @return [Refined]
45
+ def match
46
+ extract!
47
+ policy_failure_match! || self
48
+ end
49
+
50
+ # Turns value into the hash of extracted data
51
+ #
52
+ # @return [Hash]
53
+ def mapped
54
+ keys = self.class.extractors.keys
55
+ Hash[keys.zip(@context.values_at(*keys))]
56
+ end
57
+
58
+ # Extracts data from the value
59
+ #
60
+ # @return [Nothing]
61
+ #
62
+ protected def extract!
63
+ self.class.extractors.each do |field, settings|
64
+ next if !context[field].nil? && !context[field].empty?
65
+
66
+ method_name, = *settings
67
+ context[field] = send(method_name.to_s)
68
+ end
69
+ end
70
+
71
+ # Validates extracted data using policy_klass
72
+ #
73
+ # @return [Refined, Nil]
74
+ #
75
+ protected def policy_failure_match!
76
+ return unless self.class.policy
77
+
78
+ policy_input = context.reduce({}) { |a, (k, v)| a.merge!(k.to_sym => v) }
79
+ policy_instance = self.class.policy[**policy_input]
80
+ return if policy_instance.valid?
81
+
82
+ failure(policy_instance.errors)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,45 @@
1
+ module BloodContracts::Core
2
+ # Mapper in form of refinement type, transforms the value using mapper_klass
3
+ class MapValue < Refined
4
+ class << self
5
+ # Any callable object which you prefer to turn value into other form
6
+ #
7
+ # @param [Class, #call]
8
+ # @return [Class]
9
+ #
10
+ attr_accessor :mapper_klass
11
+
12
+ # Generates meta-class with predefined mapper_klass
13
+ #
14
+ # @param mapper_klass [Class, callable] callable object that will
15
+ # transform the value
16
+ # @return [MapValue]
17
+ #
18
+ def with(mapper_klass)
19
+ type = Class.new(self)
20
+ type.mapper_klass = mapper_klass
21
+ type
22
+ end
23
+ end
24
+
25
+ # Always successful matching process which transforms the value and
26
+ # store it in the context
27
+ #
28
+ # @return [Refined]
29
+ #
30
+ def match
31
+ context[:mapper_input] = value
32
+ context[:mapped_value] =
33
+ self.class.mapper_klass.call(**context[:mapper_input])
34
+ self
35
+ end
36
+
37
+ # Mapped representation of the value
38
+ #
39
+ # @return [Object]
40
+ #
41
+ def mapped
42
+ match.context[:mapped_value]
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,42 @@
1
+ module BloodContracts::Core
2
+ # ContractFailure which holds errors in form of Tram::Policy::Errors
3
+ class PolicyFailure < ContractFailure
4
+ # Extends the type with ability to generate custom errors, to wrap and
5
+ # error message into Tram::Policy::Errors
6
+ extend DefineableError.new(:contracts)
7
+
8
+ # Builds an PolicyFailure, turns the errors into Tram::Policy::Errors
9
+ # if they are not, yet
10
+ #
11
+ # @param errors_per_type [Hash<Refined, Array<String,Symbol>>] map of
12
+ # errors per type, each type could have a list of errors
13
+ # @option context [Hash] shared context of matching pipeline
14
+ #
15
+ def initialize(errors_per_type = nil, context: {}, **)
16
+ sub_scope = context.delete(:sub_scope)
17
+ errors_per_type.to_h.transform_values! do |errors|
18
+ errors.map do |error|
19
+ next(error) if error.is_a?(Tram::Policy::Errors)
20
+ self.class.define_error(error, tags: context, sub_scope: sub_scope)
21
+ end
22
+ end
23
+ super
24
+ end
25
+
26
+ # Merged list of Tram::Policy::Errors after the matching run
27
+ #
28
+ # @return [Array<Tram::Policy::Errors>]
29
+ #
30
+ def policy_errors
31
+ @policy_errors ||= @value.values.flatten
32
+ end
33
+
34
+ # Merged list of Tram::Policy::Errors messages (or their translations)
35
+ #
36
+ # @return [Array<String>]
37
+ #
38
+ def messages
39
+ @messages ||= policy_errors.map(&:messages).flatten
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ module BloodContracts::Core
2
+ # Represents failure in Sum data matching
3
+ class SumPolicyFailure < PolicyFailure
4
+ # Custom accessor for policy errors in case of Ext::Sum types composition
5
+ def policy_errors
6
+ @policy_errors ||= @context[:sum_errors].map(&:policy_errors).flatten
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,39 @@
1
+ module BloodContracts::Core
2
+ # Represents failure in Tuple data matching
3
+ class TuplePolicyFailure < PolicyFailure
4
+ # Hash of attributes (name & type pairs)
5
+ #
6
+ # @return [Hash<String, Refined>]
7
+ #
8
+ def attributes
9
+ @context[:attributes]
10
+ end
11
+
12
+ # Subset of attributes which are invalid
13
+ #
14
+ # @return [Hash<String, PolicyFailure>]
15
+ #
16
+ def attribute_errors
17
+ attributes.select { |_name, type| type.invalid? }
18
+ end
19
+
20
+ # Subset of attributes which are invalid
21
+ #
22
+ # @return [Hash<String, PolicyFailure>]
23
+ #
24
+ def attribute_messages
25
+ attribute_errors.transform_values!(&:messages)
26
+ end
27
+
28
+ # Unpacked matching errors in form of a hash per attribute
29
+ #
30
+ # @return [Hash<String, PolicyFailure>]
31
+ #
32
+ def unpack_h
33
+ @unpack_h ||= attribute_errors.transform_values(&:unpack)
34
+ end
35
+ alias to_hash unpack_h
36
+ alias to_h unpack_h
37
+ alias unpack_attributes unpack_h
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ module BloodContracts::Core
2
+ module Ext
3
+ # Refinement types representation of Sum types composition, extended version
4
+ class Pipe < ::BC::Pipe
5
+ # Sets the default failure_klass to PolicyFailure, to use
6
+ # Tram::Policy::Errors for errors
7
+ self.failure_klass = PolicyFailure
8
+
9
+ # @private
10
+ def self.inherited(new_klass)
11
+ new_klass.failure_klass ||= failure_klass
12
+ super
13
+ end
14
+
15
+ # Generate an PolicyFailure from the error, also stores the
16
+ # additional scope for Tram::Policy::Errors in the context
17
+ #
18
+ # @param (see BC::Refined#failure)
19
+ # @return [PolicyFailure]
20
+ #
21
+ def failure(*, **)
22
+ @context[:sub_scope] = self.class.name
23
+ super
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,59 @@
1
+ module BloodContracts::Core
2
+ module Ext
3
+ # Refinement type exteneded with Extractable, ExceptionHandling and
4
+ # errors representation in form of Tram::Policy::Errors
5
+ class Refined < ::BC::Refined
6
+ # Adds ability to generate custom errors in form of Tram::Policy::Errors
7
+ extend DefineableError.new(:contracts)
8
+
9
+ # Adds extractors DSL
10
+ include Extractable
11
+
12
+ # Adds exception handling in form of refinment type
13
+ prepend ExceptionHandling
14
+
15
+ # Sets the default failure_klass to PolicyFailure, to use
16
+ # Tram::Policy::Errors for errors
17
+ self.failure_klass = PolicyFailure
18
+
19
+ class << self
20
+ # Compose types in a Sum check
21
+ # Sum passes data from type to type in parallel, only one type
22
+ # have to match
23
+ #
24
+ # @return [BC::Sum]
25
+ #
26
+ def or_a(other_type)
27
+ BC::Ext::Sum.new(self, other_type)
28
+ end
29
+
30
+ # Compose types in a Pipe check
31
+ # Pipe passes data from type to type sequentially
32
+ #
33
+ # @return [BC::Pipe]
34
+ #
35
+ def and_then(other_type)
36
+ BC::Ext::Pipe.new(self, other_type)
37
+ end
38
+
39
+ # @private
40
+ def inherited(new_klass)
41
+ new_klass.failure_klass ||= failure_klass
42
+ new_klass.prepend ExceptionHandling
43
+ super
44
+ end
45
+ end
46
+
47
+ # Generate an PolicyFailure from the error, also stores the
48
+ # additional scope for Tram::Policy::Errors in the context
49
+ #
50
+ # @param (see BC::Refined#failure)
51
+ # @return [PolicyFailure]
52
+ #
53
+ def failure(*, **)
54
+ @context[:sub_scope] = self.class.name
55
+ super
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,29 @@
1
+ module BloodContracts::Core
2
+ module Ext
3
+ # Refinement types representation of Sum types composition, extended version
4
+ class Sum < ::BC::Sum
5
+ # Sets the default failure_klass to PolicyFailure, to use
6
+ # Tram::Policy::Errors for errors
7
+ self.failure_klass = SumPolicyFailure
8
+
9
+ # @private
10
+ def self.inherited(new_klass)
11
+ new_klass.failure_klass ||= failure_klass
12
+ super
13
+ end
14
+
15
+ # Generate an PolicyFailure from the error, also stores the
16
+ # additional scope for Tram::Policy::Errors in the context.
17
+ # Also saves the Sum errors in the context
18
+ #
19
+ # @param (see BC::Refined#failure)
20
+ # @return [PolicyFailure]
21
+ #
22
+ def failure(*, **)
23
+ @context[:sum_errors] = @or_matches
24
+ @context[:sub_scope] = self.class.name
25
+ super
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ module BloodContracts::Core
2
+ module Ext
3
+ # Refinement types representation of Sum types composition, extended version
4
+ class Tuple < ::BC::Tuple
5
+ # Sets the default failure_klass to PolicyFailure, to use
6
+ # Tram::Policy::Errors for errors
7
+ self.failure_klass = TuplePolicyFailure
8
+
9
+ # @private
10
+ def self.inherited(new_klass)
11
+ new_klass.failure_klass ||= failure_klass
12
+ super
13
+ end
14
+
15
+ # Generate an PolicyFailure from the error, also stores the
16
+ # additional scope for Tram::Policy::Errors in the context.
17
+ # Also saves the Tuple error in the context[:attributes] by the :base key
18
+ #
19
+ # @param (see BC::Refined#failure)
20
+ # @return [PolicyFailure]
21
+ #
22
+ def failure(*, **)
23
+ @context[:sub_scope] = self.class.name
24
+ @context[:attributes].store(:base, super)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ require "blood_contracts/core"
2
+ require "tram-policy"
3
+
4
+ # Top-level scope for BloodContracts toolset
5
+ module BloodContracts
6
+ # Scope for refinement types & helpers for them
7
+ module Core
8
+ require_relative "core/defineable_error.rb"
9
+
10
+ require_relative "core/policy_failure.rb"
11
+ require_relative "core/tuple_policy_failure.rb"
12
+ require_relative "core/sum_policy_failure.rb"
13
+ require_relative "core/exception_caught.rb"
14
+ require_relative "core/exception_handling.rb"
15
+ require_relative "core/extractable.rb"
16
+
17
+ # Scope for extended refinement types
18
+ module Ext
19
+ require_relative "ext/refined.rb"
20
+ require_relative "ext/sum.rb"
21
+ require_relative "ext/pipe.rb"
22
+ require_relative "ext/tuple.rb"
23
+ end
24
+
25
+ require_relative "core/expected_error.rb"
26
+ require_relative "core/map_value.rb"
27
+ end
28
+ end
@@ -0,0 +1,50 @@
1
+ RSpec.describe BloodContracts::Core::ExceptionCaught do
2
+ before do
3
+ module Test
4
+ class JsonType < BC::Refined
5
+ prepend BC::ExceptionHandling
6
+
7
+ def match
8
+ @context[:json_type_input] = value
9
+ @context[:parsed_json] = JSON.parse(@context[:json_type_input])
10
+ self
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ subject { Test::JsonType.match(value) }
17
+
18
+ context "when value is valid" do
19
+ let(:value) { '{"some": "thing"}' }
20
+ let(:payload) { { "some" => "thing" } }
21
+
22
+ it do
23
+ is_expected.to be_valid
24
+ expect(subject.context[:json_type_input]).to eq(value)
25
+ expect(subject.context[:parsed_json]).to match(payload)
26
+ end
27
+ end
28
+
29
+ context "when value is invalid" do
30
+ context "when value is a String" do
31
+ let(:value) { "nope, I'm not a JSON" }
32
+
33
+ it do
34
+ is_expected.to be_invalid
35
+ expect(subject.context[:json_type_input]).to eq(value)
36
+ expect(subject.exception).to match(kind_of(JSON::ParserError))
37
+ end
38
+ end
39
+
40
+ context "when value is a arbitrary object" do
41
+ let(:value) { Class.new }
42
+
43
+ it do
44
+ is_expected.to be_invalid
45
+ expect(subject.context[:json_type_input]).to eq(value)
46
+ expect(subject.exception).to match(kind_of(TypeError))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,56 @@
1
+ RSpec.describe BloodContracts::Core::ExpectedError do
2
+ before do
3
+ module Test
4
+ class PlainTextError < BC::ExpectedError
5
+ def match
6
+ @context[:parsed] ||= JSON.parse(value)
7
+ rescue JSON::ParserError
8
+ @context[:plain_text] = value.to_s
9
+ self
10
+ end
11
+ end
12
+
13
+ class JsonType < BC::Ext::Refined
14
+ def match
15
+ @context[:parsed] ||= JSON.parse(value)
16
+ self
17
+ end
18
+
19
+ def mapped
20
+ @context[:parsed]
21
+ end
22
+ end
23
+
24
+ Response = JsonType.or_a(PlainTextError)
25
+ end
26
+ end
27
+
28
+ subject { Test::Response.match(value) }
29
+
30
+ context "when value is a JSON" do
31
+ let(:value) { '{"name": "Andrew", "registered_at": "2019-01-01"}' }
32
+ let(:payload) do
33
+ { "name" => "Andrew", "registered_at" => "2019-01-01" }
34
+ end
35
+
36
+ it do
37
+ is_expected.to be_valid
38
+ is_expected.to match(kind_of(Test::JsonType))
39
+ expect(subject.unpack).to match(payload)
40
+ end
41
+ end
42
+
43
+ context "when value is a plain text" do
44
+ let(:value) { "Nothing found!" }
45
+ let(:error_messages) do
46
+ ["Service responded with a message: `#{value}`"]
47
+ end
48
+
49
+ it do
50
+ is_expected.to be_valid
51
+ expect(subject).to match(kind_of(Test::PlainTextError))
52
+ expect(subject.unpack).to match(kind_of(Tram::Policy::Errors))
53
+ expect(subject.unpack.messages).to match(error_messages)
54
+ end
55
+ end
56
+ end