(with functional approach)
Dolganov Sergey @ Evil Martians
Budapest.rb August '19
Distributed system with lots of API integrations and calls
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
(screen from Reddit e.g.)
What would be your next surprise?
eBay Orders,
eBaymag Parcels
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
... 2 YEARS OF FIGHTING API ISSUES ...
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
{ n :: Integer | n > 5 }
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
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
SOMEONE ALREADY IMPLEMENTED CONTRACTS
WITH FINE DSL BUT FAILED TO ADOPT...
I'M PRETTY SURE OUR SOLUTION IS MORE APPROPRIATE
Ruby gem (ongoing Node, Go)
Contracts fit best for a functional block that interacts with external systems
Contracts help us to manage growing entropy