blood_contracts-ext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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