[![Build Status](https://travis-ci.org/sclinede/blood_contracts-ext.svg?branch=master)][travis] [![Code Climate](https://codeclimate.com/github/sclinede/blood_contracts-ext/badges/gpa.svg)][codeclimate] [gem]: https://rubygems.org/gems/blood_contracts-ext [travis]: https://travis-ci.org/sclinede/blood_contracts-ext [codeclimate]: https://codeclimate.com/github/sclinede/blood_contracts-ext # BloodContracts::Ext 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. ## Installation Add this line to your application's Gemfile: ```ruby gem 'blood_contracts-ext' ``` And then execute: $ bundle Or install it yourself as: $ gem install blood_contracts-ext ## Usage This gems consists mostly of Concerns and Refined classes that extends the powers of refinement types. ### BC::ExceptionHandling 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). ```ruby 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) # => # match.exception # => TypeError match.context # => { :json_type_input => #, :exception => TypeError } ``` Now you have access to both the exception (the `#exception` reader) and matching context (the `#context` reader). ### BC::DefinableError 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. ```ruby 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") # => # # 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. ```ruby 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") # => # # 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: ```ruby case match = Email.match("not-an-email") when Email # Validation succeeded # Use #unpack or #context to extract the data match # => # when BC::PolicyFailure # You have access here to #message and #policy_errors methods match # => # when BC::ContractFailure # No fancy Tram::Policy integration but anyway #unpack or #messages at your serivce match # => # else raise # Remember to be exhaustive end ``` ### BC::MapValue 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: ```ruby 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)) # => # 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::Extractable 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 ```ruby 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: ```ruby address_model = Address.new("RU", "Moscow", "Novoslobodskaya street") AddressType.match(address_model) # => # json_address = '{"CountryCode": "RU", "City": "Moscow", "StreetLine": "ul. Novoslobodskaya"}' AddressType.match(JSON.parse(json_address)) # => # AddressType.match("anything_else") # => # ``` ### BC::PolicyFailure 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. ```ruby 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. ### BC::Ext::Refined 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). ### BC::ExpectedError 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. ```ruby 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": ...}') # => # match = RubygemsAPI::Response.match('Project not found!') # => # # translation key: en.contracts.rubygems_api/plain_text_error.message match.unpack # => "Service responded with a message: `Project not found!`" ``` ### Summary 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](https://github.com/sclinede/blood_contracts-ext/issues/new) ## Development 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](https://rubygems.org). ## Contributing 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](http://contributor-covenant.org) code of conduct. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of Conduct Everyone interacting in the BloodContracts::Ext project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/sclinede/blood_contracts-ext/blob/master/CODE_OF_CONDUCT.md).