sclinede/blood_contracts-core

View on GitHub
examples/tariff_contract.rb

Summary

Maintainability
A
3 hrs
Test Coverage
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