blood_contracts-core 0.2.1 → 0.3.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.
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