Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Dirty Magic for Resilient API Dependencies

Dirty Magic for Resilient API Dependencies

Imagine that you want to build a system which depends on external service, e.g., logistics, payments or notifications service. Those systems have its life-cycle which you have to be in sync with. I’ll share how to treat issues you could face, using the examples of DHL, UPS, Russian Post integrations.

Sergey Dolganov

September 07, 2019
Tweet

More Decks by Sergey Dolganov

Other Decks in Programming

Transcript

  1. 2

  2. 2

  3. 2

  4. 3

  5. 4

  6. 5

  7. 6

  8. 8

  9. 9

  10. 9

  11. 9

  12. 10

  13. 10

  14. 10

  15. 11

  16. 11

  17. 11

  18. 12

  19. 12

  20. 13

  21. 13

  22. 14

  23. 17

  24. 18

  25. 18

  26. 18

  27. 18

  28. 19

  29. 19

  30. 19

  31. 19

  32. 20

  33. 20

  34. 20

  35. 20

  36. 21

  37. 21

  38. 21

  39. 21

  40. 26 Layout (for DHL) eBay Orders, eBaymag Parcels Lots of

    validations HTTP Client Confusing content
  41. 27 module DHL module API def get_tariff(mapped_request) sampler = TariffSampler.new

    sampler.register_request(mapped_request) response = http_client.get("/GetQuote", mapped_request.to_xml) sampler.register_response(response) sampler.tag! response end
  42. 28 class TariffSampler < BaseSampler def tag! tag_success! tag_dhl_address_ambiguity_warning! tag_dhl_composite_call_warning!

    tag_data_invalid_input_error! tag_destination_invalid_for_dhl! tag_unexpected_behavior! if tags.empty? end # ... end Response data “Contract”
  43. 29 module DHL class Calculator < ::BaseService attr_reader :errors param

    :parcel def call policy = DHL::RegionalPolicy[parcel] return if policy.invalid? policy = DHL::Requests::TariffPolicy[parcel] return if policy.invalid? response = DHL::API.get_tariff(Requests::TariffMapper[parcel]) mapped_response = Responses::TariffMapper[response] policy = DHL::Responses::TariffPolicy[response, mapped_response] return if policy.invalid? parcel.merge_update!(dhl: mapped_response) ensure @errors = policy&.errors end end end
  44. 30 module DHL module Responses class TariffPolicy < Tram::Policy param

    :response param :mapped_response validate :no_response_errors, stop_on_failure: true validate :delivery_cost_presence validate :delivery_currency_presence validate :delivery_date_presence def no_response_errors return if response.errors.empty? errors.add :dhl_responded_with_error, errors: response.errors end # implementation of other methods end end
  45. 31 module DHL class Calculator < ::BaseService attr_reader :errors param

    :parcel def call policy = DHL::RegionalPolicy[parcel] return if policy.invalid? policy = DHL::Requests::TariffSchemaPolicy[parcel] return if policy.invalid? response = DHL::API.get_tariff(Requests::TariffMapper[parcel]) mapped_response = Responses::TariffMapper[response] policy = DHL::Responses::TariffPolicy[response, mapped_response] return if policy.invalid? parcel.merge_update!(dhl: mapped_response) ensure @errors = policy&.errors end end end
  46. 32 class DHL::Requests::TariffMapper extend Dry::Initializer option :parcel, optional: true option

    :origin, default: -> { default_origin } option :destination, default: -> { default_destination } option :reference_id, default: -> { default_reference_id } option :value, default: -> { default_value } option :weight, default: -> { default_weight } # other parameters def call [ route_attributes, size_weight_attributes, package_attributes, ].reduce(:merge) end end
  47. 37 Sagas. Orchestration approach 1. Wrap each mutation in a

    Command 2.Chain Command 3.Execute chain 4.If one fails run `undo`
  48. 46

  49. 51 Algebraic Data Types module UPS module Rates # types

    product, # Request passes both RegionalParcel and Schema contracts RequestPolicy = RegionalPolicy.and_then(SchemaPolicy) # types sum, # Error passes either RecoverableInputError or # InvalidRequest or BasicError contract ErrorPolicy = RecoverableInputErrorPolicy .or(InvalidRequestPolicy) .or(BasicErrorPolicy) # combine ResponsePolicy = JSONResponsePolicy.and_then(TariffPolicy.or(ErrorPolicy)) end end
  50. 53

  51. 54

  52. 55

  53. 56

  54. 59 Refinement Type module Types class JSONResponse < BaseType alias

    :response :value def match context[:body] = response.body context[:parsed] = ::JSON.parse(response.body) self rescue StandardError => error ContractFailure.new(error, context) end def mapped context[:parsed] end end end
  55. 60 Refinement Type module UPS module Shipping class InputSchemaType <

    BaseType extract :origin extract :destination extract :weight def match extract! data = context.slice(*self.class.extractables) return self if ParcelPolicy[**data].valid? PolicyFailure.new(policy.errors, context) end def mapped context.slice(*self.class.extractables) end end end end Advanced
  56. 61 class UPS::RatesContract < BaseContract # sampling by types def

    match(*input) input_match = Rates::RequestType.match(*input) return input_match if input_match.invalid? result = yield(input_match) Rates::ResponseType.match(result, input_match.context) end end
  57. 62 module UPS module Rates # types product, # Request

    passes both RegionalPolicy and SchemaInput RequestType = RegionalPolicyType.and_then(InputSchemaType) # types sum, # Error passes either RecoverableInputError or # InvalidRequest or BasicError contract ErrorType = RecoverableInputErrorType .or(InvalidRequestType) .or(BasicErrorType) # combine ResponseType = Types::JSONResponse.and_then(Types::LogisticsTariff.or(ErrorType)) end end Contract Definiton
  58. 64 case (match = UPS::API.rate_request(parcel)) when LogisticsTariff render json: {

    tariff: match.unpack } when PolicyFailure render json: { errors: match.errors } when InvalidRequest Honeybadger.notify "Invalid UPS request", context: match.context render json: { errors: "Unprocessable now, try later" } when ContractFailure Honeybadger.notify "Unexpected UPS behavior", context: match.context render json: { errors: "Unprocessable now, try later" } else raise "Invalid contract processing" end
  59. 65 Monitoring module BC class YabedaInstrument def call(session) valid_marker =

    session.valid? ? "V" : "I" result = "[#{valid_marker}] #{session.result_type_name}" Yabeda.api_contract_matches.increment(result: result) end end end BloodContracts::Instrumentation.configure do |cfg| # Attach to every BC::Refined ancestor matching UPS.*Contract cfg.instrument /UPS.*Contract/, BC::YabedaInstrument.new # Uses Sniffer to record Requests and Responses cfg.instrument /UPS.*Contract/, BC::SnifferInstrument.new end
  60. 65 Monitoring module BC class YabedaInstrument def call(session) valid_marker =

    session.valid? ? "V" : "I" result = "[#{valid_marker}] #{session.result_type_name}" Yabeda.api_contract_matches.increment(result: result) end end end BloodContracts::Instrumentation.configure do |cfg| # Attach to every BC::Refined ancestor matching UPS.*Contract cfg.instrument /UPS.*Contract/, BC::YabedaInstrument.new # Uses Sniffer to record Requests and Responses cfg.instrument /UPS.*Contract/, BC::SnifferInstrument.new end