Refinement types are implemented in BloodContracts::Core, but in production we found several patterns to use with types. Let me share them with you.
Welcome, extended refinement types.
All those extensions are listed below, stay tuned.
Add this line to your application's Gemfile:
gem 'blood_contracts-ext'
And then execute:
$ bundle
Or install it yourself as:
$ gem install blood_contracts-ext
This gems consists mostly of Concerns and Refined classes that extends the powers of refinement types.
First of all sometimes it is great to replace the usual exception handling with refinement types, because inside type you have much more context then just the exception and its backtrace.
For that scenario you only need to prepend your BC::Refined class with BC::ExceptionHandling and when the StandardError happen inside your matching pipeline it will turn into BC::ExceptionCaught type (which is of course just another ancestor of BC::ContractFailure).
class JsonType < BC::Refined
prepend BC::ExceptionHandling
def match
@context[:json_type_input] = value
@context[:parsed_json] = JSON.parse(@context[:json_type_input])
self
end
end
match = JsonType.match(Class.new) # => #<BC::ExceptionCaught ...>
match.exception # => TypeError
match.context # => { :json_type_input => #<Class>, :exception => TypeError }
Now you have access to both the exception (the #exception
reader) and matching context (the #context
reader).
Imagine you have an error message you want to return for your validation, but you have to worry about the translations.
With BC::DefineableError you don't have to. You just extend your class with BC::DefinableError.new(:translations_root)
and
you have simple DSL to define translatable and composable errors.
class EmailType < ::BC::Refined
extend BC::DefineableError.new(:type_validations)
REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
INVALID_EMAIL = define_error(:invalid_email)
def match
context[:email_input] = value.to_s
return failure(INVALID_EMAIL) if context[:email_input] !~ REGEX
context[:email] = context[:email_input]
self
end
end
match = Email.match("not-an-email") # => #<BC::ContractFailure ...>
# en.yml should include translation for en.type_validations.email_type.invalid_email
# e.g. "Given value is not a valid email address"
match.errors.reduce(:merge).messages # => ["Given value is not a valid email address"]
Of course you may prefer a shortcut here, when you use ::BC::Ext::Refined as a base class your failures are wrapped into BC::PolicyFailure with even better Tram::Policy integration.
class EmailType < ::BC::Ext::Refined
extend BC::DefineableError.new(:type_validations)
REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
INVALID_EMAIL = define_error(:invalid_email)
def match
context[:email_input] = value.to_s
return failure(INVALID_EMAIL) if context[:email_input] !~ REGEX
context[:email] = context[:email_input]
self
end
end
match = Email.match("not-an-email") # => #<BC::PolicyFailure ...>
# en.yml should include translation for en.type_validations.email_type.invalid_email
# e.g. "Given value is not a valid email address"
match.messages # => ["Given value is not a valid email address"]
As simple as that! Do you still remember our "patter matching" usage? It's working anyways:
case match = Email.match("not-an-email")
when Email
# Validation succeeded
# Use #unpack or #context to extract the data
match # => #<Email ...>
when BC::PolicyFailure
# You have access here to #message and #policy_errors methods
match # => #<BC::PolicyFailure ...>
when BC::ContractFailure
# No fancy Tram::Policy integration but anyway #unpack or #messages at your serivce
match # => #<BC::ContractFailure>
else raise # Remember to be exhaustive
end
Another usual scenario is to transform the value of your type but when logic is too complex you prefer to use another class for that. For that case you may try BC::MapValue type which will be regular part of your pipeline.
Let's imagine you want to change transform your ActiveModel object to some json through the class. Not a big deal, look at the example:
module UPS
class JsonRequests::Rates
def self.call(origin_country:, destination_country:, weight:)
JSON.pretty_generate(
"RateRequest": {
"Shipment": {
"ShipFrom": origin_country,
"ShipTo": destination_country,
"Service": { "Code": "65" },
"Package": {
"PackagingType": { "Code": "00" },
"PackageWeight": {
"UnitOfMeasurement": { "Code": "KGS" },
"Weight": weight.to_s,
}
}
}
}
)
end
end
class ParcelType < BC::Refined
prepend BC::ExceptionHandling
def match
parcel = value
context.merge!(
origin_country: parcel.origin_address.country,
destination_country: parcel.destination_address.country,
weight: parcel.weight
)
end
def mapped
@context.slice(:origin_country, :destination_country, :weight)
end
end
RatesRequestType = ParcelType.and_then(BC::MapValue.with(JsonRequests::Rates))
end
match = UPS::RatesRequestType.match(Parcel.find(123)) # => #<BC::MapValue ...>
match.unpack # =>
# => {
# "RateRequest": {
# "Shipment": {
# "ShipFrom": "LV",
# "ShipTo": "US",
# "Service": { "Code": "65" },
# "Package": {
# "PackagingType": { "Code": "00" },
# "PackageWeight": {
# "UnitOfMeasurement": { "Code": "KGS" },
# "Weight": "1.15"
# }
# }
# }
# }
# }
UPS::RatesRequestType.match("not-a-parcel") # => #<BC::ExceptionCaught ...>
You may notice that in huge number of cases your type is a coercer from an arbitrary object. So you may look at the Refinement type as "extractor". That only means you have to use several methods to parse the context from the value.
That best example is attempt to use single type for different types of input
class AddressType < BC::Refined
extend BC::Extractable
prepend BC::ExceptionHandling
extract :city
extract :country_code, method_name: :country
extract :street
def city
return value.city if value.respond_to?(:city)
value.to_h
.transform_keys(&:to_s)
.values_at("city", "City")
.compact
.first
end
def country
return value.country if value.respond_to?(:country)
value.to_h
.transform_keys(&:to_s)
.values_at("country", "country_code", "CountryCode")
.compact
.first
end
def street
return value.street if value.respond_to?(:street)
value.to_h
.transform_keys(&:to_s)
.values_at("street", "street_line", "StreetLine")
.compact
.first
end
end
Address = Struct.new(:country, :city, :street)
That's just a definition, but let's take a look how it will behave in runtime:
address_model = Address.new("RU", "Moscow", "Novoslobodskaya street")
AddressType.match(address_model) # => #<AddressType ...>
json_address = '{"CountryCode": "RU", "City": "Moscow", "StreetLine": "ul. Novoslobodskaya"}'
AddressType.match(JSON.parse(json_address)) # => #<AddressType ...>
AddressType.match("anything_else") # => #<BC::ExceptionCaught ...>
There is a great abstraction for validation called Policy object. I like the Tram::Policy implementation, so now you're able to delegate validation logic to an external Policy object.
But, sometimes you may prefer to use only Tram::Policy::Errors abstraction for the matching errors.
For that case, you just need to use self.failure_klass = BC::PolicyFailure
in your type.
class Phone < ::BC::Refined
self.failure_klass = BC::PolicyFailure
REGEX = /\A(\+7|8)(9|8)\d{9}\z/i
def match
context[:phone_input] = value.to_s
clean_phone = context[:phone_input].gsub(/[\s\(\)-]/, "")
# translation key is: en.tram-policy.phone.invalid_phone
return failure(:invalid_phone) if clean_phone !~ REGEX
context[:clean_phone] = clean_phone
self
end
end
Not a big difference? But, now all your failure calls generate Tram::Policy::Error, which easily translates using I18n.
You just saw several fancy tools around the BC::Refined. So, why don't we have everything inside that class? Because we try to keep things simple and transparent. But.
If you prefer to have all that tooling in your types - "easy-peasy", use brand new BC::Ext::Refined.
BC::Ext::Refined - is just extended version of BC::Refined (extended by concerns mentioned above).
Finally, when you validate responses from API, sometimes "error" is just one of expected scenarios. That is why you may prefer special base class for those matching cases.
Welcome - BC::ExpectedError, it's just ancestor of BC::Ext::Refined and by default it maps the context to Tram::Policy::Errors.
module RubygemsAPI
class PlainTextError < BC::ExpectedError
def match
@context[:parsed] ||= JSON.parse(value)
rescue JSON::ParserError
@context[:plain_text] = value.to_s
self
end
end
class JsonType < BC::Ext::Refined
def match
@context[:parsed] ||= JSON.parse(value)
self
end
def mapped
@context[:parsed]
end
end
Response = JsonType.or_a(PlainTextError)
end
RubygemsAPI::Response.match('{"project": ...}') # => #<JsonType ...>
match = RubygemsAPI::Response.match('Project not found!') # => #<PlainTextError ...>
# translation key: en.contracts.rubygems_api/plain_text_error.message
match.unpack # => "Service responded with a message: `Project not found!`"
That covers all the relevant scenarios for types and contract validations. If you have a case that is not covered and you find it useful - feel free to open an Issue
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/sclinede/blood_contracts-ext. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the BloodContracts::Ext project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.