examples/tariff_contract.rb
require "bundler/setup"
require "json"
require "blood_contracts/core"
require "pry"
module Types
class ExceptionCaught < BC::ContractFailure; end
class Base < BC::Refined
def exception(ex, context: @context)
ExceptionCaught.new({ exception: ex }, context: context)
end
end
class JSON < Base
def _match
context[:parsed] = ::JSON.parse(unpack_refined(@value))
nil
rescue StandardError => error
exception(error)
end
def _unpack(match)
match.context[:parsed]
end
end
end
module RussianPost
class DomesticTariffMapper
def self.call(parcel)
{
"mass": parcel.weight,
"mail-from": parcel.origin_postal_code,
"mail-to": parcel.destination_postal_code,
}
end
end
class InputValidationFailure < BC::ContractFailure; end
class DomesticParcel < Types::Base
self.failure_klass = InputValidationFailure
alias parcel value
def match
return failure(key: :undef_weight, field: :weight) unless parcel.weight
return if domestic?
failure(non_domestic_error)
rescue StandardError => error
exception(error)
end
def mapped
DomesticTariffMapper.call(parcel)
end
private
def domestic?
[parcel.origin_country, parcel.destination_country].all?("RU")
end
def non_domestic_error
{
key: :non_domestic_parcel,
context: {
origin: parcel.origin_country,
destination: parcel.destination_country,
}
}
end
end
class InternationalTariffMapper
def self.call(parcel)
{
"mass": parcel.weight,
"mail-direct": parcel.destination_country,
}
end
end
class InternationalParcel < Types::Base
self.failure_klass = InputValidationFailure
alias parcel value
def match
return failure(key: :undef_weight, field: :weight) unless parcel.weight
return failure(not_from_ru_error) if parcel_outside_ru?
return failure(non_international_error) if non_international_parcel?
nil
rescue StandardError => error
exception(error)
end
def mapped
InternationalTariffMapper.call(parcel)
end
private
def parcel_outside_ru?
parcel.origin_country != "RU"
end
def non_international_parcel?
parcel.destination_country == "RU"
end
def not_from_ru_error
{
key: :parcel_is_not_from_ru,
context: {
origin: parcel.origin_country,
}
}
end
def non_international_error
{ key: :parcel_is_not_international }
end
end
class RecoverableInputError < Types::Base
alias parsed_response value
def match
return if [error_code, error_message].all?
failure(key: :not_a_recoverable_error)
rescue StandardError => error
exception(error)
end
def error_message
@error_message ||= parsed_response["desc"]
@error_message ||= parsed_response["error-details"]&.join("; ")
end
private
def error_code
parsed_response.values_at("code", "error-code").compact.first
end
end
class OtherError < Types::Base
alias parsed_response value
def match
return unless error_code.nil?
failure(key: :not_a_known_error)
rescue StandardError => error
exception(error)
end
private
def error_code
parsed_response.values_at("code", "error-code", "status").compact.first
end
end
class DomesticTariff < Types::Base
alias parsed_response value
def match
return if is_a_domestic_tariff?
context[:raw_response] = parsed_response
failure(key: :not_a_domestic_tariff)
rescue StandardError => error
exception(error)
end
def cost
@cost ||= delivery_cost / 100.0
end
private
def is_a_domestic_tariff?
[delivery_cost, delivery_date, cost].all?
end
def delivery_cost
parsed_response["total-cost"]
end
def delivery_date
@delivery_date ||= parsed_response["delivery-till"]
end
end
class InternationalTariff < Types::Base
alias parsed_response value
def match
return if is_an_international_tariff?
context[:raw_response] = parsed_response
failure(key: :not_an_international_tariff)
rescue StandardError => error
exception(error)
end
def cost
@cost ||= (delivery_rate + delivery_vat) / 100.0
end
private
def is_an_international_tariff?
[delivery_rate, delivery_vat, cost].all?
end
def delivery_rate
parsed_response["total-rate"]
end
def delivery_vat
parsed_response["total-vat"]
end
end
end
module RussianPost
KnownError = RecoverableInputError | OtherError
DomesticResponse =
(Types::JSON.and_then(DomesticTariff | KnownError)).set(names: %i[parsed mapped])
InternationalResponse =
(Types::JSON.and_then(InternationalTariff | KnownError)).set(names: %i[parsed mapped])
TariffRequestContract = ::BC::Contract.new(
DomesticParcel => DomesticResponse,
InternationalParcel => InternationalResponse
)
end
def contractable_request_tariff(input)
RussianPost::TariffRequestContract.match(input) do |refined_parcel|
request_tariff(refined_parcel.unpack)
end
end
def match_response(response)
case response
when RussianPost::InputValidationFailure
# работаем с тарифом
puts "render json: { errors: 'Parcel is invalid for request (#{response.to_h})' }"
when RussianPost::DomesticTariff
# работаем с тарифом
puts "render json: { context: 'inside Russia only!', cost: #{response.cost} }"
when RussianPost::InternationalTariff
# работаем с тарифом
puts "render json: { context: 'outside Russia only!', cost_inc_vat: #{response.cost} }"
when RussianPost::RecoverableInputError
# работаем с ошибкой, e.g. адрес слишком длинный
puts "render json: { errors: [#{response.error_message}] } }"
when RussianPost::OtherError
# работаем с ошибкой, e.g. адрес слишком длинный
puts "Honeybadger.notify 'Non-recoverable error from Russian Post API', context: #{pp(response.context)}"
puts "render json: { errors: ['Sorry, API could not process your request, we've been notified. Try again later'] } }"
when Types::ExceptionCaught
puts "Honeybadger.notify #{response.errors_h[:exception]}"
when BC::ContractFailure
puts "Honeybadger.notify 'Unexpected behavior in Russian Post API Client', context:"
puts " 'Unexpected behavior in Russian Post API Client'"
puts " context:"
pp(response.context)
puts "render json: { errors: 'Ooops! Not working, we've been notified. Please, try again later' }"
else
require"pry"; binding.pry
end
end
# DEMO STUFF
Stuff = Struct.new(:daaamn, keyword_init: true)
Parcel = Struct.new(
:weight, :origin_country, :origin_postal_code, :destination_country,
:destination_postal_code,
keyword_init: true
)
PARCELS = [
# domestic without weight
Parcel.new(weight: nil, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
# not from RU
Parcel.new(weight: 123, origin_country: "US", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
# domestic
Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
# international
Parcel.new(weight: 123, origin_country: "RU", origin_postal_code: "123", destination_country: "RU", destination_postal_code: "123"),
# not a parcel
Stuff.new(daaamn: "WTF?!")
]
RESPONSES = [
'{"total-cost": 10000, "delivery-till": "2019-12-12"}',
'{"total-rate": 100000, "total-vat": 1800}',
'{"total-rate": "some", "total-vat": "text"}',
'{"code": 1010, "desc": "Too long address"}',
'{"error-code": 2020, "error-details": ["Too heavy parcel"]}'
]
def run_tests(runs: ENV["RUNS"] || 10)
runs.to_i.times do
input = PARCELS.sample
puts "#{'=' * 20}================================#{'=' * 20}"
puts "\n\n\n"
puts "#{'=' * 20}================================#{'=' * 20}"
puts "#{'=' * 20} WHEN INPUT: #{'=' * 20}"
pp(input)
match = contractable_request_tariff(input)
puts "#{'=' * 20}================================#{'=' * 20}"
puts "#{'=' * 20} ACTION: #{'=' * 20}"
match_response(match)
puts "#{'=' * 20}================================#{'=' * 20}"
end
end
def request_tariff(request)
puts "#{'=' * 20}================================#{'=' * 20}"
puts "#{'=' * 20} AND THEN REQUEST: #{'=' * 20}"
pp(request)
puts "#{'=' * 20}================================#{'=' * 20}"
puts "#{'=' * 20} AND THEN RESPONSE: #{'=' * 20}"
response = RESPONSES.sample
puts response
response
end
run_tests