blood_contracts-core 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15305efec69e9366ea2169a4abf832a1619cc87e5aeef2be0e0d324e4fe49db7
4
- data.tar.gz: 50cc4a0a8ba539dcb28ff31f29dfc0e487633753542b30ca5af8222ada5d57c0
3
+ metadata.gz: 85f39b273547e389e3938ab33f8182857ef7c0d8f573e5664cb877c40c4d991a
4
+ data.tar.gz: a8e6d07a8311096fdb4a83d7c30a510807b6713a29ad620b4e0e174c09448346
5
5
  SHA512:
6
- metadata.gz: 94b4bc3793c8a4259f0111daca712e3949361df4b72ce2e95cac9b5e5d64754bf1694e717eb134cb83249b7b028211995c000344afa64fc2b198d11f33817a1d
7
- data.tar.gz: 2b43b3fa1b14b0166bfab0aee21406075179de2d58bf68fbce54eece179cc8334b07b5246c2864383f0fdbbf356b5cc018ec0f3b987b5937ef3c34558c5b3f0b
6
+ metadata.gz: 726747a300fa76dab9a86ac8efe2f79aae54bfadee6c12989a377833754cf5f912d585b214c23370e4b6bf75eecba05684b5f816ac6eb66a3b208505935ccf9d
7
+ data.tar.gz: dd2d045b398fc3d8a82da25226a2b9f8cddc92e9b451111aa20b8c5ca4e44706d197d0b2eef665e776ef8d4d004ac57409d2c3c260bd650b624bb4da2ce9884d
data/.gitignore CHANGED
@@ -9,3 +9,4 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+ Gemfile.lock
@@ -0,0 +1,357 @@
1
+ require "bundler/setup"
2
+ require "json"
3
+ require 'blood_contracts/core'
4
+ require "pry"
5
+
6
+ module Types
7
+ class JSON < BC::Refined
8
+ def match
9
+ super do
10
+ begin
11
+ context[:parsed] = ::JSON.parse(unpack_refined(@value))
12
+ self
13
+ rescue StandardError => error
14
+ failure(error)
15
+ end
16
+ end
17
+ end
18
+
19
+ def unpack
20
+ super { |match| match.context[:parsed] }
21
+ end
22
+ end
23
+ end
24
+
25
+ module RussianPost
26
+ class DomesticTariffMapper
27
+ def self.call(parcel)
28
+ {
29
+ "mass": parcel.weight,
30
+ "mail-from": parcel.origin_postal_code,
31
+ "mail-to": parcel.destination_postal_code,
32
+ }
33
+ end
34
+ end
35
+
36
+ class InputValidationFailure < BC::ContractFailure; end
37
+ class ExceptionCaught < BC::ContractFailure; end
38
+
39
+ class BaseType < BC::Refined
40
+ def exception(ex, context: @context)
41
+ ExceptionCaught.new({ exception: ex }, context: context)
42
+ end
43
+ end
44
+
45
+ class DomesticParcel < BaseType
46
+ self.failure_klass = InputValidationFailure
47
+
48
+ alias :parcel :value
49
+ def _match
50
+ return failure(key: :undef_weight, field: :weight) unless parcel.weight
51
+ return self if domestic?
52
+ failure(non_domestic_error)
53
+ rescue StandardError => error
54
+ exception(error)
55
+ end
56
+
57
+ def _unpack(match)
58
+ DomesticTariffMapper.call(match.parcel)
59
+ end
60
+
61
+ private
62
+
63
+ def domestic?
64
+ [parcel.origin_country, parcel.destination_country].all?("RU")
65
+ end
66
+
67
+ def non_domestic_error
68
+ {
69
+ key: :non_domestic_parcel,
70
+ context: {
71
+ origin: parcel.origin_country,
72
+ destination: parcel.destination_country,
73
+ }
74
+ }
75
+ end
76
+ end
77
+
78
+ class InternationalTariffMapper
79
+ def self.call(parcel)
80
+ {
81
+ "mass": parcel.weight,
82
+ "mail-direct": parcel.destination_country,
83
+ }
84
+ end
85
+ end
86
+
87
+ class InternationalParcel < BaseType
88
+ self.failure_klass = InputValidationFailure
89
+
90
+ alias :parcel :value
91
+ def _match
92
+ return failure(key: :undef_weight, field: :weight) unless parcel.weight
93
+ return failure(not_from_ru_error) if parcel_outside_ru?
94
+ return failure(non_international_error) if non_international_parcel?
95
+ self
96
+ rescue StandardError => error
97
+ exception(error)
98
+ end
99
+
100
+ def _unpack(match)
101
+ InternationalTariffMapper.call(match.parcel)
102
+ end
103
+
104
+ private
105
+
106
+ def parcel_outside_ru?
107
+ parcel.origin_country != "RU"
108
+ end
109
+
110
+ def non_international_parcel?
111
+ parcel.destination_country == "RU"
112
+ end
113
+
114
+ def not_from_ru_error
115
+ {
116
+ key: :parcel_is_not_from_ru,
117
+ context: {
118
+ origin: parcel.origin_country,
119
+ }
120
+ }
121
+ end
122
+
123
+ def non_international_error
124
+ { key: :parcel_is_not_international }
125
+ end
126
+ end
127
+
128
+ class RecoverableInputError < BC::Refined
129
+ alias :parsed_response :value
130
+ def match
131
+ super do
132
+ begin
133
+ next self if [error_code, error_message].all?
134
+ failure(not_a_recoverable_error)
135
+ rescue StandardError => error
136
+ failure(error)
137
+ end
138
+ end
139
+ end
140
+
141
+ def error_message
142
+ @error_message ||= parsed_response["desc"]
143
+ @error_message ||= parsed_response["error-details"]&.join("; ")
144
+ end
145
+
146
+ private
147
+
148
+ def not_a_recoverable_error
149
+ { key: :not_a_recoverable_error }
150
+ end
151
+
152
+ def error_code
153
+ parsed_response.values_at("code", "error-code").compact.first
154
+ end
155
+ end
156
+
157
+ class OtherError < BC::Refined
158
+ alias :parsed_response :value
159
+ def match
160
+ super do
161
+ begin
162
+ next failure({key: :not_a_known_error}) if error_code.nil?
163
+ self
164
+ rescue StandardError => error
165
+ failure(error)
166
+ end
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def error_code
173
+ parsed_response.values_at("code", "error-code", "status").compact.first
174
+ end
175
+ end
176
+
177
+ class DomesticTariff < BC::Refined
178
+ alias :parsed_response :value
179
+ def match
180
+ super do
181
+ begin
182
+ next self if is_a_domestic_tariff?
183
+ context[:raw_response] = parsed_response
184
+ failure({key: :not_a_domestic_tariff})
185
+ rescue StandardError => error
186
+ failure(error)
187
+ end
188
+ end
189
+ end
190
+
191
+ def cost
192
+ @cost ||= delivery_cost / 100.0
193
+ end
194
+
195
+ private
196
+
197
+ def is_a_domestic_tariff?
198
+ [delivery_cost, delivery_date, cost].all?
199
+ end
200
+
201
+ def delivery_cost
202
+ parsed_response["total-cost"]
203
+ end
204
+
205
+ def delivery_date
206
+ @delivery_date ||= parsed_response["delivery-till"]
207
+ end
208
+ end
209
+
210
+ class InternationalTariff < BC::Refined
211
+ alias :parsed_response :value
212
+ def match
213
+ super do
214
+ begin
215
+ next self if is_an_international_tariff?
216
+ context[:raw_response] = parsed_response
217
+ failure({key: :not_an_international_tariff})
218
+ rescue StandardError => error
219
+ failure(error)
220
+ end
221
+ end
222
+ end
223
+
224
+ def cost
225
+ @cost ||= (delivery_rate + delivery_vat) / 100.0
226
+ end
227
+
228
+ private
229
+
230
+ def is_an_international_tariff?
231
+ [delivery_rate, delivery_vat, cost].all?
232
+ end
233
+
234
+ def delivery_rate
235
+ parsed_response["total-rate"]
236
+ end
237
+
238
+ def delivery_vat
239
+ parsed_response["total-vat"]
240
+ end
241
+ end
242
+ end
243
+
244
+ module RussianPost
245
+ KnownError = RecoverableInputError | OtherError
246
+
247
+ DomesticResponse =
248
+ (Types::JSON > (DomesticTariff | KnownError)).set(names: %i(parsed mapped))
249
+ InternationalResponse =
250
+ (Types::JSON > (InternationalTariff | KnownError)).set(names: %i(parsed mapped))
251
+
252
+ TariffRequestContract = ::BC::Contract.new(
253
+ DomesticParcel => DomesticResponse,
254
+ InternationalParcel => InternationalResponse,
255
+ )
256
+ end
257
+
258
+ def contractable_request_tariff(input)
259
+ RussianPost::TariffRequestContract.match(input) do |refined_parcel|
260
+ request_tariff(refined_parcel.unpack)
261
+ end
262
+ end
263
+
264
+ def match_response(response)
265
+ case response
266
+ when RussianPost::ExceptionCaught
267
+ puts "Honeybadger.notify #{response.errors_h[:exception]}"
268
+ when RussianPost::InputValidationFailure
269
+ # работаем с тарифом
270
+ puts "render json: { errors: 'Parcel is invalid for request (#{response.to_h})' }"
271
+ when RussianPost::DomesticTariff
272
+ # работаем с тарифом
273
+ puts "render json: { context: 'inside Russia only!', cost: #{response.cost} }"
274
+ when RussianPost::InternationalTariff
275
+ # работаем с тарифом
276
+ puts "render json: { context: 'outside Russia only!', cost_inc_vat: #{response.cost} }"
277
+ when RussianPost::RecoverableInputError
278
+ # работаем с ошибкой, e.g. адрес слишком длинный
279
+ puts "render json: { errors: [#{response.error_message}] } }"
280
+ when RussianPost::OtherError
281
+ # работаем с ошибкой, e.g. адрес слишком длинный
282
+ puts "Honeybadger.notify 'Non-recoverable error from Russian Post API', context: #{pp(response.context)}"
283
+ puts "render json: { errors: ['Sorry, API could not process your request, we've been notified. Try again later'] } }"
284
+ when BC::ContractFailure
285
+ puts "Honeybadger.notify 'Unexpected behavior in Russian Post API Client', context:"
286
+ puts " 'Unexpected behavior in Russian Post API Client'"
287
+ puts " context:"
288
+ pp(response.context)
289
+ puts "render json: { errors: 'Ooops! Not working, we've been notified. Please, try again later' }"
290
+ else
291
+ require'pry';binding.pry
292
+ end
293
+ end
294
+
295
+ # DEMO STUFF
296
+
297
+ Stuff = Struct.new(:daaamn, keyword_init: true)
298
+ Parcel = Struct.new(
299
+ :weight, :origin_country, :origin_postal_code, :destination_country,
300
+ :destination_postal_code,
301
+ keyword_init: true
302
+ )
303
+
304
+ PARCELS = [
305
+ # domestic without weight
306
+ Parcel.new(weight: nil, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
307
+
308
+ # not from RU
309
+ Parcel.new(weight: 123, origin_country: "US", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
310
+
311
+ # domestic
312
+ Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
313
+
314
+ # international
315
+ Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123" ),
316
+
317
+ # not a parcel
318
+ Stuff.new(daaamn: "WTF?!")
319
+ ]
320
+
321
+ RESPONSES = [
322
+ '{"total-cost": 10000, "delivery-till": "2019-12-12"}',
323
+ '{"total-rate": 100000, "total-vat": 1800}',
324
+ '{"code": 1010, "desc": "Too long address"}',
325
+ '{"error-code": 2020, "error-details": ["Too heavy parcel"]}',
326
+ ]
327
+
328
+ def run_tests(runs: ENV["RUNS"] || 10)
329
+ runs.to_i.times do
330
+ input = PARCELS.sample
331
+ puts "#{'=' * 20}================================#{'=' * 20}"
332
+ puts "\n\n\n"
333
+ puts "#{'=' * 20}================================#{'=' * 20}"
334
+ puts "#{'=' * 20} WHEN INPUT: #{'=' * 20}"
335
+ pp(input)
336
+ match = contractable_request_tariff(input)
337
+ puts "#{'=' * 20}================================#{'=' * 20}"
338
+ puts "#{'=' * 20} ACTION: #{'=' * 20}"
339
+ match_response(match)
340
+ puts "#{'=' * 20}================================#{'=' * 20}"
341
+ end
342
+ end
343
+
344
+ def request_tariff(request)
345
+ puts "#{'=' * 20}================================#{'=' * 20}"
346
+ puts "#{'=' * 20} AND THEN REQUEST: #{'=' * 20}"
347
+ pp(request)
348
+
349
+ puts "#{'=' * 20}================================#{'=' * 20}"
350
+ puts "#{'=' * 20} AND THEN RESPONSE: #{'=' * 20}"
351
+ response = RESPONSES.sample
352
+ puts response
353
+
354
+ response
355
+ end
356
+
357
+ run_tests
@@ -2,15 +2,26 @@ module BloodContracts
2
2
  module Core
3
3
  class Contract
4
4
  class << self
5
- attr_accessor :input_type, :output_type
6
- end
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
7
19
 
8
- def self.call(*args)
9
- if (input_match = input_type.match(*args)).valid?
10
- result = yield(input_match)
11
- output_type.match(result, context: input_match.context)
12
- else
13
- input_match
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
14
25
  end
15
26
  end
16
27
  end
@@ -28,6 +28,14 @@ module BloodContracts
28
28
  pipe
29
29
  end
30
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
31
39
  end
32
40
 
33
41
  def match
@@ -43,27 +51,27 @@ module BloodContracts
43
51
  end
44
52
 
45
53
  break match if match.invalid?
46
- next refine_value(yield(match)) if block_given?
54
+ if block_given? && index < self.class.steps.size
55
+ next refine_value(yield(match))
56
+ end
47
57
  match
48
58
  end
49
59
  end
50
60
  end
51
61
 
52
- private
53
-
54
- def step_name(index)
62
+ private def step_name(index)
55
63
  self.class.names[index] || index
56
64
  end
57
65
 
58
- def steps_with_names
66
+ private def steps_with_names
59
67
  steps = if self.class.names.empty?
60
- self.class.steps.map(&:to_s)
68
+ self.class.steps.map(&:inspect)
61
69
  else
62
- self.class.steps.zip(self.class.names).map { |k, n| "#{k}(#{n})" }
70
+ self.class.steps.zip(self.class.names).map { |k, n| "#{k.inspect}(#{n})" }
63
71
  end
64
72
  end
65
73
 
66
- def inspect
74
+ private def inspect
67
75
  "#<pipe #{self.class.name} = #{steps_with_names.join(' > ')} (value=#{@value})>"
68
76
  end
69
77
  end
@@ -14,7 +14,11 @@ module BloodContracts
14
14
  alias :> :and_then
15
15
 
16
16
  def match(*args)
17
- new(*args).match
17
+ if block_given?
18
+ new(*args).match { |*subargs| yield(*subargs) }
19
+ else
20
+ new(*args).match
21
+ end
18
22
  end
19
23
  alias :call :match
20
24
 
@@ -22,6 +26,18 @@ module BloodContracts
22
26
  return object.to_ary.any?(self) if object.is_a?(Tuple)
23
27
  super
24
28
  end
29
+
30
+ def set(**kwargs)
31
+ kwargs.each do |setting, value|
32
+ send(:"#{setting}=", value)
33
+ end
34
+ self
35
+ end
36
+
37
+ attr_accessor :failure_klass
38
+ def inherited(new_klass)
39
+ new_klass.failure_klass ||= ContractFailure
40
+ end
25
41
  end
26
42
 
27
43
  attr_accessor :context
@@ -36,6 +52,7 @@ module BloodContracts
36
52
  def match
37
53
  return @match if defined? @match
38
54
  return @match = yield if block_given?
55
+ return @match = _match if respond_to?(:_match)
39
56
  self
40
57
  end
41
58
  alias :call :match
@@ -48,13 +65,15 @@ module BloodContracts
48
65
  def unpack
49
66
  raise "This is not what you're looking for" if match.invalid?
50
67
  return yield(match) if block_given?
68
+ return @match = _unpack(match) if respond_to?(:_unpack)
51
69
 
52
70
  unpack_refined @value
53
71
  end
54
72
 
55
- def failure(error = nil, errors: @errors, context: @context)
73
+ def failure(error = nil, errors: @errors, context: @context, **kwargs)
74
+ error ||= kwargs unless kwargs.empty?
56
75
  errors << error if error
57
- ContractFailure.new(
76
+ self.class.failure_klass.new(
58
77
  { self.class => errors }, context: context
59
78
  )
60
79
  end
@@ -79,21 +98,25 @@ module BloodContracts
79
98
  end
80
99
 
81
100
  def errors_by_type(matches)
82
- Hash[
83
- matches.map(&:class).zip(matches.map(&:errors))
84
- ].delete_if { |_, errors| errors.empty? }
101
+ matches.map(&:errors).reduce(:+).delete_if(&:empty?)
85
102
  end
86
103
  end
87
104
 
88
105
  class ContractFailure < Refined
89
- def initialize(*)
106
+ def initialize(value = nil, **)
90
107
  super
91
- @context.merge!(errors: @value.to_h)
108
+ return unless @value
109
+ @context[:errors] = (@context[:errors].to_a << @value.to_h)
92
110
  end
93
111
 
94
112
  def errors
95
- context[:errors]
113
+ @context[:errors].to_a
114
+ end
115
+
116
+ def errors_h
117
+ errors.reduce(:merge)
96
118
  end
119
+ alias :to_h :errors_h
97
120
 
98
121
  def match
99
122
  self
@@ -9,20 +9,29 @@ module BloodContracts
9
9
  def new(*args)
10
10
  return super if finalized
11
11
 
12
+ new_sum = args.reduce([]) { |acc, type| type.respond_to?(:sum_of) ? acc + type.sum_of.to_a : acc << type }
13
+
12
14
  sum = Class.new(Sum) { def inspect; super; end }
13
- sum.instance_variable_set(:@sum_of, ::Set.new(args))
15
+ sum.instance_variable_set(:@sum_of, ::Set.new(new_sum.compact))
14
16
  sum.instance_variable_set(:@finalized, true)
15
17
  sum
16
18
  end
17
19
 
18
20
  def or_a(other_type)
19
21
  sum = Class.new(Sum) { def inspect; super; end }
20
- sum.instance_variable_set(:@sum_of, ::Set.new(self.sum_of << other_type))
22
+ new_sum = self.sum_of.to_a
23
+ new_sum += other_type.sum_of.to_a if other_type.respond_to?(:sum_of)
24
+ sum.instance_variable_set(:@sum_of, ::Set.new(new_sum.compact))
21
25
  sum.instance_variable_set(:@finalized, true)
22
26
  sum
23
27
  end
24
28
  alias :or_an :or_a
25
29
  alias :| :or_a
30
+
31
+ def inspect
32
+ return super if self.name
33
+ "Sum(#{self.sum_of.map(&:inspect).join(',')})"
34
+ end
26
35
  end
27
36
 
28
37
  def match
@@ -32,10 +41,11 @@ module BloodContracts
32
41
  end
33
42
 
34
43
  if (match = or_matches.find(&:valid?))
35
- match.context[:errors].merge(errors_by_type(or_matches))
36
44
  match
37
45
  else
38
- failure(errors: errors_by_type(or_matches))
46
+ or_matches.first
47
+ # just use the context
48
+ # ContractFailure.new(context: context)
39
49
  end
40
50
  end
41
51
  end
@@ -17,6 +17,10 @@ module BloodContracts
17
17
  tuple.instance_variable_set(:@finalized, true)
18
18
  tuple
19
19
  end
20
+
21
+ private
22
+
23
+ attr_writer :names
20
24
  end
21
25
 
22
26
  attr_reader :values
@@ -1,5 +1,5 @@
1
1
  module BloodContracts
2
2
  module Core
3
- VERSION = "0.2.1"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
@@ -1,5 +1,6 @@
1
1
  require_relative "./core/refined.rb"
2
2
  require_relative "./core/pipe.rb"
3
+ require_relative "./core/contract.rb"
3
4
  require_relative "./core/sum.rb"
4
5
  require_relative "./core/tuple.rb"
5
6
  require_relative "./core/version.rb"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blood_contracts-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Dolganov
@@ -85,6 +85,7 @@ files:
85
85
  - bin/setup
86
86
  - blood_contracts-core.gemspec
87
87
  - examples/json_response.rb
88
+ - examples/tariff_contract.rb
88
89
  - examples/tuple.rb
89
90
  - lib/blood_contracts-core.rb
90
91
  - lib/blood_contracts/core.rb