blood_contracts-core 0.3.5 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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