Sergey Dolganov
Evil Martians developer, open-source enthusiast, traveller and drummer.
(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
By Sergey Dolganov
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.
Evil Martians developer, open-source enthusiast, traveller and drummer.