blood_contracts-core 0.3.5 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  require "bundler/setup"
2
2
  require "json"
3
- require 'blood_contracts/core'
3
+ require "blood_contracts/core"
4
4
  require "pry"
5
5
 
6
6
  module Types
@@ -14,7 +14,7 @@ module Types
14
14
  class JSON < Base
15
15
  def _match
16
16
  context[:parsed] = ::JSON.parse(unpack_refined(@value))
17
- self
17
+ nil
18
18
  rescue StandardError => error
19
19
  exception(error)
20
20
  end
@@ -41,17 +41,18 @@ module RussianPost
41
41
  class DomesticParcel < Types::Base
42
42
  self.failure_klass = InputValidationFailure
43
43
 
44
- alias :parcel :value
45
- def _match
44
+ alias parcel value
45
+ def match
46
46
  return failure(key: :undef_weight, field: :weight) unless parcel.weight
47
47
  return if domestic?
48
+
48
49
  failure(non_domestic_error)
49
50
  rescue StandardError => error
50
51
  exception(error)
51
52
  end
52
53
 
53
- def _unpack(match)
54
- DomesticTariffMapper.call(match.parcel)
54
+ def mapped
55
+ DomesticTariffMapper.call(parcel)
55
56
  end
56
57
 
57
58
  private
@@ -83,18 +84,18 @@ module RussianPost
83
84
  class InternationalParcel < Types::Base
84
85
  self.failure_klass = InputValidationFailure
85
86
 
86
- alias :parcel :value
87
- def _match
87
+ alias parcel value
88
+ def match
88
89
  return failure(key: :undef_weight, field: :weight) unless parcel.weight
89
90
  return failure(not_from_ru_error) if parcel_outside_ru?
90
91
  return failure(non_international_error) if non_international_parcel?
91
- self
92
+ nil
92
93
  rescue StandardError => error
93
94
  exception(error)
94
95
  end
95
96
 
96
- def _unpack(match)
97
- InternationalTariffMapper.call(match.parcel)
97
+ def mapped
98
+ InternationalTariffMapper.call(parcel)
98
99
  end
99
100
 
100
101
  private
@@ -122,9 +123,10 @@ module RussianPost
122
123
  end
123
124
 
124
125
  class RecoverableInputError < Types::Base
125
- alias :parsed_response :value
126
- def _match
126
+ alias parsed_response value
127
+ def match
127
128
  return if [error_code, error_message].all?
129
+
128
130
  failure(key: :not_a_recoverable_error)
129
131
  rescue StandardError => error
130
132
  exception(error)
@@ -143,10 +145,11 @@ module RussianPost
143
145
  end
144
146
 
145
147
  class OtherError < Types::Base
146
- alias :parsed_response :value
147
- def _match
148
- return failure(key: :not_a_known_error) if error_code.nil?
149
- self
148
+ alias parsed_response value
149
+ def match
150
+ return unless error_code.nil?
151
+
152
+ failure(key: :not_a_known_error)
150
153
  rescue StandardError => error
151
154
  exception(error)
152
155
  end
@@ -159,8 +162,8 @@ module RussianPost
159
162
  end
160
163
 
161
164
  class DomesticTariff < Types::Base
162
- alias :parsed_response :value
163
- def _match
165
+ alias parsed_response value
166
+ def match
164
167
  return if is_a_domestic_tariff?
165
168
  context[:raw_response] = parsed_response
166
169
  failure(key: :not_a_domestic_tariff)
@@ -188,8 +191,8 @@ module RussianPost
188
191
  end
189
192
 
190
193
  class InternationalTariff < Types::Base
191
- alias :parsed_response :value
192
- def _match
194
+ alias parsed_response value
195
+ def match
193
196
  return if is_an_international_tariff?
194
197
  context[:raw_response] = parsed_response
195
198
  failure(key: :not_an_international_tariff)
@@ -221,13 +224,13 @@ module RussianPost
221
224
  KnownError = RecoverableInputError | OtherError
222
225
 
223
226
  DomesticResponse =
224
- (Types::JSON.and_then(DomesticTariff | KnownError)).set(names: %i(parsed mapped))
227
+ (Types::JSON.and_then(DomesticTariff | KnownError)).set(names: %i[parsed mapped])
225
228
  InternationalResponse =
226
- (Types::JSON.and_then(InternationalTariff | KnownError)).set(names: %i(parsed mapped))
229
+ (Types::JSON.and_then(InternationalTariff | KnownError)).set(names: %i[parsed mapped])
227
230
 
228
231
  TariffRequestContract = ::BC::Contract.new(
229
232
  DomesticParcel => DomesticResponse,
230
- InternationalParcel => InternationalResponse,
233
+ InternationalParcel => InternationalResponse
231
234
  )
232
235
  end
233
236
 
@@ -239,8 +242,6 @@ end
239
242
 
240
243
  def match_response(response)
241
244
  case response
242
- when Types::ExceptionCaught
243
- puts "Honeybadger.notify #{response.errors_h[:exception]}"
244
245
  when RussianPost::InputValidationFailure
245
246
  # работаем с тарифом
246
247
  puts "render json: { errors: 'Parcel is invalid for request (#{response.to_h})' }"
@@ -257,6 +258,8 @@ def match_response(response)
257
258
  # работаем с ошибкой, e.g. адрес слишком длинный
258
259
  puts "Honeybadger.notify 'Non-recoverable error from Russian Post API', context: #{pp(response.context)}"
259
260
  puts "render json: { errors: ['Sorry, API could not process your request, we've been notified. Try again later'] } }"
261
+ when Types::ExceptionCaught
262
+ puts "Honeybadger.notify #{response.errors_h[:exception]}"
260
263
  when BC::ContractFailure
261
264
  puts "Honeybadger.notify 'Unexpected behavior in Russian Post API Client', context:"
262
265
  puts " 'Unexpected behavior in Russian Post API Client'"
@@ -264,7 +267,7 @@ def match_response(response)
264
267
  pp(response.context)
265
268
  puts "render json: { errors: 'Ooops! Not working, we've been notified. Please, try again later' }"
266
269
  else
267
- require'pry';binding.pry
270
+ require"pry"; binding.pry
268
271
  end
269
272
  end
270
273
 
@@ -279,16 +282,16 @@ Parcel = Struct.new(
279
282
 
280
283
  PARCELS = [
281
284
  # domestic without weight
282
- Parcel.new(weight: nil, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
285
+ Parcel.new(weight: nil, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
283
286
 
284
287
  # not from RU
285
- Parcel.new(weight: 123, origin_country: "US", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
288
+ Parcel.new(weight: 123, origin_country: "US", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
286
289
 
287
290
  # domestic
288
- Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
291
+ Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
289
292
 
290
293
  # international
291
- Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
294
+ Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
292
295
 
293
296
  # not a parcel
294
297
  Stuff.new(daaamn: "WTF?!")
@@ -299,7 +302,7 @@ RESPONSES = [
299
302
  '{"total-rate": 100000, "total-vat": 1800}',
300
303
  '{"total-rate": "some", "total-vat": "text"}',
301
304
  '{"code": 1010, "desc": "Too long address"}',
302
- '{"error-code": 2020, "error-details": ["Too heavy parcel"]}',
305
+ '{"error-code": 2020, "error-details": ["Too heavy parcel"]}'
303
306
  ]
304
307
 
305
308
  def run_tests(runs: ENV["RUNS"] || 10)
data/examples/tuple.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require "bundler/setup"
2
2
  require "json"
3
- require 'blood_contracts/core'
3
+ require "blood_contracts/core"
4
4
  require "pry"
5
5
 
6
6
  module Types
@@ -8,7 +8,7 @@ module Types
8
8
  def match
9
9
  super do
10
10
  begin
11
- context[:parsed] = ::JSON.parse(unpack_refined(@value))
11
+ context[:parsed] = ::JSON.parse(value)
12
12
  self
13
13
  rescue StandardError => error
14
14
  failure(error)
@@ -23,14 +23,10 @@ module Types
23
23
 
24
24
  class Symbol < BC::Refined
25
25
  def match
26
- super do
27
- begin
28
- context[:as_symbol] = unpack_refined(@value).to_sym
29
- self
30
- rescue StandardError => error
31
- failure(error)
32
- end
33
- end
26
+ context[:as_symbol] = value.to_sym
27
+ self
28
+ rescue StandardError => error
29
+ failure(error)
34
30
  end
35
31
 
36
32
  def unpack
@@ -39,7 +35,10 @@ module Types
39
35
  end
40
36
  end
41
37
 
42
- Config = BC::Tuple.new(Types::Symbol, Types::JSON, names: %i(name config))
38
+ Config = BC::Tuple.new do
39
+ attribute :name, Types::Symbol
40
+ attribute :config, Types::JSON
41
+ end
42
+
43
43
  c = Config.new("test", '{"some": "value"}')
44
44
  binding.pry
45
-
@@ -0,0 +1,23 @@
1
+ module BloodContracts::Core
2
+ # Refinement type which represents data which is always correct
3
+ class Anything < Refined
4
+
5
+ # The type which is the result of data matching process
6
+ # (for Anything is always self)
7
+ #
8
+ # @return [BC::Refined]
9
+ #
10
+ def match
11
+ self
12
+ end
13
+
14
+ # Checks whether the data matches the expectations or not
15
+ # (for Anything is always true)
16
+ #
17
+ # @return [Boolean]
18
+ #
19
+ def valid?
20
+ true
21
+ end
22
+ end
23
+ end
@@ -1,28 +1,42 @@
1
- module BloodContracts
2
- module Core
3
- class Contract
4
- class << self
5
- def new(*args)
6
- input, output =
7
- if (opts = args.last).is_a?(Hash)
8
- accumulate_contract = opts.reduce({}) do |acc, (input, output)|
9
- prev_input, prev_output = acc.first
10
- { (input | prev_input) => (output | prev_output) }
11
- end
12
- accumulate_contract.first
13
- else
14
- _validate_args!(args)
15
- args
16
- end
17
- BC::Pipe.new(input, output, names: %i(input output))
18
- end
1
+ module BloodContracts::Core
2
+ # Meta refinement type, represents contract built upon input and output
3
+ # types
4
+ class Contract
5
+ class << self
6
+ # Metaprogramming around constructor
7
+ # Turns input types into a contract
8
+ #
9
+ # @param [Hash<BC::Refined, BC::Refined>] expectations about possible
10
+ # data input expressed in form of BC::Refined types
11
+ # @return [BC::Refined] contract for data validation in form of
12
+ # BC::Refined class
13
+ #
14
+ def new(*args)
15
+ input, output =
16
+ if (opts = args.last).is_a?(Hash)
17
+ accumulate_contract(opts)
18
+ else
19
+ _validate_args!(args)
20
+ args
21
+ end
22
+ BC::Pipe.new(input, output, names: %i[input output])
23
+ end
24
+
25
+ # @private
26
+ private def _validate_args!(args)
27
+ return if args.size == 2
28
+ raise ArgumentError, <<~MESSAGE
29
+ wrong number of arguments (given #{args.size}, expected 2)
30
+ MESSAGE
31
+ end
19
32
 
20
- def _validate_args!(args)
21
- return if args.size == 2
22
- raise ArgumentError, <<~MESSAGE
23
- wrong number of arguments (given #{args.size}, expected 2)
24
- MESSAGE
33
+ # @private
34
+ private def accumulate_contract(options)
35
+ accumulate_contract = options.reduce({}) do |acc, (input, output)|
36
+ prev_input, prev_output = acc.first
37
+ { (input | prev_input) => (output | prev_output) }
25
38
  end
39
+ accumulate_contract.first
26
40
  end
27
41
  end
28
42
  end
@@ -0,0 +1,50 @@
1
+ module BloodContracts::Core
2
+ # Refinement type which represents invalid data
3
+ class ContractFailure < Refined
4
+ # Constructs a ContractsFailure using the given value
5
+ # (for ContractFailure value is an error)
6
+ #
7
+ # @return [ContractFailure]
8
+ #
9
+ def initialize(value = nil, **)
10
+ super
11
+ @match = self
12
+ return unless @value
13
+ @context[:errors] = (@context[:errors].to_a << @value.to_h)
14
+ end
15
+
16
+ # List of errors per type after the data matching process
17
+ #
18
+ # @return [Array<Hash<BC::Refined, String>>]
19
+ #
20
+ def errors
21
+ @context[:errors].to_a
22
+ end
23
+
24
+ # Flatten list of error messages
25
+ #
26
+ # @return [Array<String>]
27
+ #
28
+ def messages
29
+ errors.reduce(:merge).values.flatten!
30
+ end
31
+
32
+ # Merged map of errors per type after the data matching process
33
+ #
34
+ # @return [Hash<BC::Refined, String>]
35
+ #
36
+ def errors_h
37
+ errors.reduce(:merge)
38
+ end
39
+ alias to_h errors_h
40
+
41
+ # The type which is the result of validation
42
+ # (for ContractFailure is always self)
43
+ #
44
+ # @return [BC::Refined]
45
+ #
46
+ def match
47
+ self
48
+ end
49
+ end
50
+ end
@@ -1,95 +1,161 @@
1
- module BloodContracts
2
- module Core
3
- class Pipe < Refined
4
- class << self
5
- attr_reader :steps, :names, :finalized
6
-
7
- def new(*args, **kwargs)
8
- return super(*args, **kwargs) if finalized
9
- unless kwargs.empty?
10
- names = kwargs.delete(:names)
11
- end
12
- names ||= []
13
-
14
- raise ArgumentError unless args.all?(Class)
15
- pipe = Class.new(Pipe) { def inspect; super; end }
16
- pipe.instance_variable_set(:@steps, args)
17
- pipe.instance_variable_set(:@names, names)
18
- pipe.instance_variable_set(:@finalized, true)
19
- pipe
20
- end
21
-
22
- def and_then(other_type, **kwargs)
23
- raise ArgumentError unless Class === other_type
24
- pipe = Class.new(Pipe) { def inspect; super; end }
25
- pipe.instance_variable_set(:@steps, [self, other_type])
26
- pipe.instance_variable_set(:@names, kwargs[:names].to_a)
27
- pipe.instance_variable_set(:@finalized, true)
28
- pipe
29
- end
30
- alias :> :and_then
31
-
32
- def inspect
33
- self.name || "Pipe(#{self.steps.to_a.join(',')})"
34
- end
35
-
36
- private
37
-
38
- attr_writer :names
1
+ module BloodContracts::Core
2
+ # Meta refinement type, represents pipe of several refinement types
3
+ class Pipe < Refined
4
+ class << self
5
+ # List of data transformation step
6
+ #
7
+ # @return [Array<Refined>]
8
+ #
9
+ attr_reader :steps
10
+
11
+ # List of data transformation step names
12
+ #
13
+ # @return [Array<Symbol>]
14
+ #
15
+ attr_reader :names
16
+
17
+ # rubocop:disable Style/SingleLineMethods
18
+ def new(*args, **kwargs, &block)
19
+ return super(*args, **kwargs) if @finalized
20
+ names = kwargs.delete(:names) unless kwargs.empty?
21
+ names ||= []
22
+
23
+ raise ArgumentError unless args.all? { |type| type < Refined }
24
+ pipe = Class.new(Pipe) { def inspect; super; end }
25
+ finalize!(pipe, args, names)
26
+ pipe.class_eval(&block) if block_given?
27
+ pipe
39
28
  end
40
29
 
41
- def initialize(*)
42
- super
43
- @context[:types] = @context[:types].to_a
30
+ # Compose types in a Pipe check
31
+ # Pipe passes data from type to type sequentially
32
+ #
33
+ # @return [BC::Pipe]
34
+ #
35
+ # rubocop:disable Style/CaseEquality
36
+ def and_then(other_type, **kwargs)
37
+ raise ArgumentError unless Class === other_type
38
+ pipe = Class.new(Pipe) { def inspect; super; end }
39
+ finalize!(pipe, [self, other_type], kwargs[:names].to_a)
40
+ pipe
44
41
  end
45
-
46
- def match
47
- super do
48
- index = 0
49
- self.class.steps.reduce(value) do |next_value, step|
50
- unpacked_value = unpack_refined(next_value)
51
- match = step.match(unpacked_value, context: @context)
52
-
53
- @context[:steps][step_name(index)] = unpacked_value
54
- types << match.class.name unless current_type.eql?(match.class.name)
55
- index += 1
56
-
57
- break match if match.invalid?
58
- if block_given? && index < self.class.steps.size
59
- next refine_value(yield(match))
60
- end
61
- match
62
- end
42
+ # rubocop:enable Style/CaseEquality Style/SingleLineMethods
43
+ alias > and_then
44
+
45
+ # Helper which registers step in validation pipe, also defines a reader
46
+ #
47
+ # @param [Symbol] name of the matching step
48
+ # @param [Refined] type of the matching step
49
+ #
50
+ def step(name, type)
51
+ raise ArgumentError unless type < Refined
52
+ @steps << type
53
+ @names << name
54
+ define_method(name) do
55
+ match.context.dig(:steps_values, name)
63
56
  end
64
57
  end
65
58
 
66
- def errors
67
- match.errors
59
+ # Returns text representation of Pipe meta-class
60
+ #
61
+ # @return [String]
62
+ #
63
+ def inspect
64
+ return super if name
65
+ "Pipe(#{steps.to_a.join(',')})"
68
66
  end
69
67
 
70
- private def types
71
- context[:types]
68
+ private def finalize!(new_class, steps, names)
69
+ new_class.instance_variable_set(:@steps, steps)
70
+ new_class.instance_variable_set(:@names, names)
71
+ new_class.instance_variable_set(:@finalized, true)
72
72
  end
73
+ end
73
74
 
74
- private def current_type
75
- context[:types].last
76
- end
75
+ # Constructs a Pipe using the given value
76
+ # (for Pipe steps are also stored in the context)
77
+ #
78
+ # @return [Pipe]
79
+ #
80
+ def initialize(*)
81
+ super
82
+ @context[:steps] = @context[:steps].to_a
83
+ end
77
84
 
78
- private def step_name(index)
79
- self.class.names[index] || index
85
+ # The type which is the result of data matching process
86
+ # For PIpe it verifies that data is valid through all data transformation
87
+ # steps
88
+ #
89
+ # @return [BC::Refined]
90
+ #
91
+ def match
92
+ steps_enumerator.reduce(value) do |next_value, (step, index)|
93
+ match = next_step_value_match!(step, next_value, index)
94
+
95
+ break match if match.invalid?
96
+ next match unless block_given?
97
+ next refine_value(yield(match)) if index < self.class.steps.size - 1
98
+
99
+ match
80
100
  end
101
+ end
81
102
 
82
- private def steps_with_names
83
- steps = if self.class.names.empty?
84
- self.class.steps.map(&:inspect)
85
- else
86
- self.class.steps.zip(self.class.names).map { |k, n| "#{k.inspect}(#{n})" }
87
- end
88
- end
103
+ # List of errors per type during the matching
104
+ #
105
+ # @return [Array<Hash<Refined, String>>]
106
+ #
107
+ def errors
108
+ @context[:errors]
109
+ end
89
110
 
90
- private def inspect
91
- "#<pipe #{self.class.name} = #{steps_with_names.join(' > ')} (value=#{@value})>"
111
+ # @private
112
+ private def next_step_value_match!(step, value, index)
113
+ unpacked_value = unpack_refined(value)
114
+ match = step.match(unpacked_value, context: @context)
115
+ track_steps!(index, unpacked_value, match.class.name)
116
+ match
117
+ end
118
+
119
+ # @private
120
+ private def steps_enumerator
121
+ self.class.steps.each_with_index
122
+ end
123
+
124
+ # @private
125
+ private def track_steps!(index, unpacked_value, match_class_name)
126
+ @context[:steps_values][step_name(index)] = unpacked_value
127
+ steps << match_class_name unless current_step.eql?(match_class_name)
128
+ end
129
+
130
+ # @private
131
+ private def steps
132
+ @context[:steps]
133
+ end
134
+
135
+ # @private
136
+ private def current_step
137
+ @context[:steps].last
138
+ end
139
+
140
+ # @private
141
+ private def step_name(index)
142
+ self.class.names[index] || index
143
+ end
144
+
145
+ # @private
146
+ private def steps_with_names
147
+ steps = self.class.steps
148
+ if self.class.names.empty?
149
+ steps.map(&:inspect)
150
+ else
151
+ steps.zip(self.class.names).map { |k, n| "#{k.inspect}(#{n})" }
92
152
  end
93
153
  end
154
+
155
+ # @private
156
+ private def inspect
157
+ "#<pipe #{self.class.name} = #{steps_with_names.join(' > ')}"\
158
+ " (value=#{@value})>"
159
+ end
94
160
  end
95
161
  end