Dolganov Sergey
@ Evil Martians
function (input) { ... } # =>
Pre-conditions
Post-conditions
Invariants
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 tags.empty?
end
# ...
end
end
end
"Contract"
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
if (service = DHL::Calculator.new(parcel)).call
render json: { tariff: parcel.reload.dhl["tariff"] }
else
render json: { errors: service.errors.to_h }
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
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
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
if (service = DHL::Calculator.new(parcel)).call
render json: { tariff: parcel.reload.dhl["tariff"] }
else
render json: { errors: service.errors.to_h }
end
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
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
if (service = DHL::Calculator.new(parcel)).call
render json: { tariff: parcel.reload.dhl["tariff"] }
else
render json: { errors: service.errors.to_h }
end
Order
Order
1. Wrap each mutation in a Command
(each could be done and undone, e.g. #up and #down in AR migration)
2. Chain Command
3. Execute chain
4. If one fails run `undo`
...
Failure
Failure
Validate
...
Success
Success
Validate
Parcel
Parcel*
...
Success
Success
Validate
PrintLabel
Validate#call
PrintLabel#call
...
Failure
Failure
Validate
PrintLabel
Validate#undo
PrintLabel#undo
(Sagas)
def create
account = Account.find(params[:account_id])
parcel = account.parcels.find(params[:id])
status =
RussianPost::Pipeline.chain(:Validate)
.chain(:GetTransportationCost)
.chain(:Register)
.chain(:PayOnline)
.chain(:PrintLabel)
.new(parcel)
.call
render json: status.payload
end
...
Success
Success
Action 1
Action N
(Action 1 Attempt)
(Action N Attempt)
Success
Failure
(Done)
Subject
call
call
Events
(Action 1 Attempt)
(Action N Attempt)
(Done)
1
2
Success
...
Success
Success
Action 1
Action N
applied?
applied?
Events
...
Success
Success
Action 1
Action N
(Action 1 Attempt)
(Action N Attempt)
Success
Failure
(Done)
Subject
call
Events
Events
(Action 1 Attempt)
(Action N Attempt)
(Done)
1
2
Failure
...
Success
Success
Action N
Action 1
undo
(reversed)
undo
(Action 1 Attempt)
(Action N Attempt)
(Done)
1
2
Failure
...
Success
Success
Action N
Action 1
undo
Failure
(reversed)
lock Subject
with error
Events
Check out in more details
module UPS
module Rates
# types product,
# Request passes both RegionalParcel and Schema contracts
RequestContract =
RegionalParcelContract.and_then(SchemaContract)
# types sum,
# Error passes either RecoverableInputError or
# InvalidRequest or BasicError contract
ErrorContract =
RecoverableInputErrorContract
.or(InvalidRequestContract)
.or(BasicErrorContract)
# combine
ResponseContract =
Contracts::JSONResponseContract.and_then(
TariffContract.or(ErrorContract)
)
end
end
{ n :: Integer | n > 5 }
module Contracts
class JSONResponseContract < BaseContract
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 unpack
match.context[:parsed]
end
end
end
module UPS
module Shipping
class ParcelContract < BaseContract
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 unpack
match.context.slice(*self.class.extractables)
end
end
end
end
Advanced
class UPS::RatesContract < BaseContract # sampling by types
def match(*input)
input_match = RequestContract.match(*input)
return input_match if input_match.invalid?
result = yield(input_match)
ResponseContract.match(result, input_match.context)
end
end
class UPS::API
def rates_request(parcel)
RatesContract.match(parcel) do |mapped_parcel|
http_client.get('/1.0/Rates', mapped_parcel.unpack)
end # returns a contract object
end
end
case (match = UPS::API.rate_request(parcel))
when TariffContract
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
module UPS
module Rates
# types product,
# Request passes both RegionalParcel and Schema contracts
RequestContract =
RegionalParcelContract.and_then(SchemaContract)
# types sum,
# Error passes either RecoverableInputError or
# InvalidRequest or BasicError contract
ErrorContract =
RecoverableInputErrorContract
.or(InvalidRequestContract)
.or(BasicErrorContract)
# combine
ResponseContract =
Contracts::JSONResponseContract.and_then(
TariffContract.or(ErrorContract)
)
end
end
class UPS::RatesContract < BaseContract # sampling by types
def match(*input)
input_match = RequestContract.match(*input)
return input_match if input_match.invalid?
result = yield(input_match)
ResponseContract.match(result, input_match.context)
end
end
class UPS::API
def rates_request(parcel)
RatesContract.match(parcel) do |mapped_parcel|
http_client.get('/1.0/Rates', mapped_parcel.unpack)
end # returns a contract object
end
end
case (match = UPS::API.rate_request(parcel))
when TariffContract
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
class UPS::RatesContract < BaseContract # sampling by types
def match(*input)
input_match = RequestContract.match(*input)
return input_match if input_match.invalid?
result = yield(input_match)
ResponseContract.match(result, input_match.context)
end
end
class UPS::API
def rates_request(parcel)
RatesContract.match(parcel) do |mapped_parcel|
http_client.get('/1.0/Rates', mapped_parcel.unpack)
end # returns a contract object
end
end
case (match = UPS::API.rate_request(parcel))
when TariffContract
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
class UPS::RatesContract < BaseContract # sampling by types
def match(*input)
input_match = RequestContract.match(*input)
return input_match if input_match.invalid?
result = yield(input_match)
ResponseContract.match(result, input_match.context)
end
end
class UPS::API
def rates_request(parcel)
RatesContract.match(parcel) do |mapped_parcel|
http_client.get('/1.0/Rates', mapped_parcel.unpack)
end # returns a contract object
end
end
case (match = UPS::API.rate_request(parcel))
when TariffContract
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
(Refinement Types)
(valid implementation of Saga is not in gem yet)
(with Refinement Types)