Writing Better Contracts

(with functional approach)

Dolganov Sergey @ Evil Martians

Budapest.rb August '19

Title Text

eBay for Business

Distributed system with lots of API integrations and calls

Sounds like a plan

  • Design by Contracts
  • Ruby implementation
  • Contract for API Client
  • Do we need contracts after all?

Contract is ...

  • Preconditions

  • Postconditions

  • Invariants

Input

Output

function (input) { ... } # =>

State

Preconditions

Postconditions

Invariants

Contract

      Contract 1 => 1
      def fact x
        x
      end

      Contract C::Num => C::Num
      def fact x
        x * fact(x - 1)
      end

      # instead of

      def fact x
        if x == 1
          x
        else
          x * fact(x - 1)
        end
      end

      Contract lambda { |n| n < 12 } => Ticket
      def get_ticket(age)
        ChildTicket.new(age: age)
      end

      Contract lambda { |n| n >= 12 } => Ticket
      def get_ticket(age)
        AdultTicket.new(age: age)
      end

«If it ain't broken,

why fix it?»

Ruby Contracts Critique

(screen from Reddit e.g.)

Looks like we don't need contracts...

API Client Problem

PRODUCTION DEBUGGING YOUR FUTURE IS

What would be your next surprise? 

eBay Orders,

eBaymag Parcels

Layout (for DHL)

Lots of validations

HTTP Client

Confusing

content

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

    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 still no tag
      end

      # ...
    end
  end
end
        module DHL::Calculator
          attr_reader :errors
          def initialize(parcel)
            @parcel = parcel
          end

          def call
            policy = InternalParcelPolicy[parcel]
            return if policy.invalid?

            policy = SchemaPolicy[parcel]
            return if policy.invalid?

            response = DHL::API.get_tariff(parcel: parcel)
            mapped_response = GetTariffResponseMapper[response]

            policy = TariffResponsePolicy[response, mapped_response]
            return if policy.invalid?

            parcel.update_tariff!(russian_post: mapped_response)
          ensure
            @errors = policy.errors
          end
        end

        if (service = RussianPost::Calculator.new(parcel)).call
          render json: { tariff: parcel.reload.tariffs[:russian_post] }
        else
          render json: { errors: { base: service.errors } }
        end
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

      # implementation of methods
  end
end

Dry::Initializer

+

Tram::Policy

+

API Sampling

... 2 YEARS OF FIGHTING API ISSUES ...

Functional Way

Algebraic Data Types

module DHL
  module Tariff
    # types product, 
    # Request passes both InternalParcel and Schema contracts
    RequestPolicy = 
      InternallParcelPolicy.and_then(SchemaPolicy)
    
    # types sum, 
    # Error passes either RecoverableInputError or 
    # InvalidRequest or BasicError contract
    ErrorPolicy = 
      RecoverableInputErrorPolicy
        .or_an(InvalidRequestPolicy)
        .or_a(BasicErrorPolicy) 

    # combine
    ResponsePolicy = 
      JSONResponsePolicy.and_then(
        TariffPolicy.or_an(ErrorPolicy)
      )
  end
end

Refinement Types

— Type (ADT)

— Predicate

{ n :: Integer | n > 5 }

Context:

"Why did validation fail?"

module BaseTypes
  class JSONResponse < BC::Refined
    alias :response :value

    def match
      context[:body] = response.body
      context[:parsed] = ::JSON.parse(response.body)
      self
    rescue StandardError => e
      ContractFailure.new(e, context) # is BC::Refined
    end

    def mapped
      context[:parsed]
    end
  end
end

Refinement Type

        class DHL::RatesContract < BC::Refined # sampling by Sniffer & types
          def match(*input)
             input_match = Request.match(*input) 
             return input_match if input_match.invalid?
           
             result = yield(input_match)
             Response.match(result)
          end
        end
        class DHL::API
          def rates_request(parcel)
            RatesContract.match(parcel) do |mapped_parcel|
              http_client.get('/1.0/Rates', mapped_parcel.unpack)
            end # returns a typed response
          end
        end
        case (match = DHL::API.rate_request(parcel))
        when ContractFailure
          Honeybadger.notify "Unexpected DHL behavior", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        when Tariff
           render json: { tariff: match.unpack }
        when RecoverableInputError
          render json: { errors: match.unpack }
        when InvalidRequest
          Honeybadger.notify "Invalid DHL request", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        else
          raise "Invalid contract processing"
        end
         DHL::RatesContract = Request.and_then(Response)
           
         # OR
         DHL::RatesContract = BC::Contract.new(Request => Response)
         
         # OR
         DHL::RatesContract = BC::Contract.new do
           step :request, Request
           step :response, Response
         end
        class DHL::RatesContract < BC::Refined # sampling by types
          def match(*input)
             input_match = Request.match(*input) 
             return input_match if input_match.invalid?
           
             result = yield(input_match)
             Response.match(result)
          end
        end
        class DHL::API
          def rates_request(parcel)
            RatesContract.match(parcel) do |mapped_parcel|
              http_client.get('/1.0/Rates', mapped_parcel.unpack)
            end # returns a typed response
          end
        end
        case (match = DHL::API.rate_request(parcel))
        when ContractFailure
          Honeybadger.notify "Unexpected DHL behavior", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        when Tariff
           render json: { tariff: match.unpack }
        when RecoverableInputError
          render json: { errors: match.unpack }
        when InvalidRequest
          Honeybadger.notify "Invalid DHL request", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        else
          raise "Invalid contract processing"
        end
        class DHL::RatesContract < BC::Refined # sampling by types
          def match(*input)
             input_match = Request.match(*input) 
             return input_match if input_match.invalid?
           
             result = yield(input_match)
             Response.match(result)
          end
        end
        class DHL::API
          def rates_request(parcel)
            RatesContract.match(parcel) do |mapped_parcel|
              http_client.get('/1.0/Rates', mapped_parcel.unpack)
            end # returns a typed response
          end
        end
        case (match = DHL::API.rate_request(parcel))
        when ContractFailure
          Honeybadger.notify "Unexpected DHL behavior", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        when Tariff
           render json: { tariff: match.unpack }
        when RecoverableInputError
          render json: { errors: match.unpack }
        when InvalidRequest
          Honeybadger.notify "Invalid DHL request", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        else
          raise "Invalid contract processing"
        end
        class DHL::RatesContract < BC::Refined # sampling by types
          def match(*input)
             input_match = Request.match(*input) 
             return input_match if input_match.invalid?
           
             result = yield(input_match)
             Response.match(result)
          end
        end
        class DHL::API
          def rates_request(parcel)
            RatesContract.match(parcel) do |mapped_parcel|
              http_client.get('/1.0/Rates', mapped_parcel.unpack)
            end # returns a typed response
          end
        end
        case (match = DHL::API.rate_request(parcel))
        when ContractFailure
          Honeybadger.notify "Unexpected DHL behavior", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        when Tariff
           render json: { tariff: match.unpack }
        when RecoverableInputError
          render json: { errors: match.unpack }
        when InvalidRequest
          Honeybadger.notify "Invalid DHL request", context: match.context
          render json: { errors: "Unprocessable now, try later" }
        else
          raise "Invalid contract processing"
        end
module Contracts
  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 DHL.*Contract
  cfg.instrument /DHL.*Contract/, Contracts::YabedaInstrument.new

  # Uses Sniffer to record Requests and Responses
  cfg.instrument /DHL.*Contract/, Contracts::SnifferInstrument.new
end

Damn!

We reinvented contracts

SOMEONE ALREADY IMPLEMENTED CONTRACTS
WITH FINE DSL BUT FAILED TO ADOPT...

I'M PRETTY SURE OUR SOLUTION IS MORE APPROPRIATE

Why Blood Contracts?

  • PORO

  • homogeneous validations design

  • full transparency of API communication 

Blood Contracts

Ruby gem (ongoing Node, Go)

Contracts fit best for a functional block that interacts with external systems

 

Contracts help us to manage growing entropy

Do we need contracts?

Further Reading

Thanks

Writing Better Contracts

By Sergey Dolganov

Writing Better Contracts

Contracts help us to manage entropy in our applications. We’ll dive into first attempt to bring Contracts approach to Ruby, why did it fail and how we’ve rehabilitated it with functional techniques and applied properly. Now we’re building our app faster with greater confidence and I’ll share how.

  • 367