BloodContracts::Core
¶ ↑
Simple and agile Ruby data validation tool inspired by refinement types and functional approach
-
Powerful. Algebraic Data Type guarantees that gem is enough to implement any kind of complex data validation, while Functional Approach gives you full control over validation outcomes
-
Simple. You could write your first Refinment Type as simple as single Ruby method in single class
-
Independent. It have no dependencies and you need nothing more to write your complex validations
-
Rubyish. DSL is inspired by Ruby Struct. If you love Ruby way you'd like the
BloodContracts
types -
Born in production. Created on basis of eBaymag project, used as a tool to control and monitor data inside API communication
# Write your "types" as simple as... class Email < ::BC::Refined REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i def match return if (context[:email] = value.to_s) =~ REGEX failure(:invalid_email) end end class Phone < ::BC::Refined REGEX = /\A(\+7|8)(9|8)\d{9}\z/i def match return if (context[:phone] = value.to_s) =~ REGEX failure(:invalid_phone) end end # ... compose them... Login = Email.or_a(Phone) # ... and match! case match = Login.match("not-a-login") when Phone, Email match # use as you wish, you exactly know what kind of login you received when BC::ContractFailure # translate error message match.messages # => [:no_matches, :invalid_phone, :invalid_email] else raise # to make sure you covered all scenarios (Functional Way) end
Installation¶ ↑
Add this line to your application's Gemfile:
gem 'blood_contracts-core'
And then execute:
$ bundle
Or install it yourself as:
$ gem install blood_contracts-core
Refinment Data Type (BC::Refined class)¶ ↑
Refinement type is an Algebraic Data Type (read, you could compose it with other types) with some predicate to check against the data. In Ruby we've implemented it as a class with method .match
which accepts single argument - value which could be any kind of object. This method ALWAYS returns ancestor of BC::Refined. So the most common usage would be:
case match = RegistrationFormType.match(params) when RegistrationFormType match.to_h # converts your data to valid Ruby hash when BC::ContractFailure match.messages # deal with error messages else raise # remember the matching should be exhaustive (simplifies debugging, I promise 🙏) end
To create your first type just inherit class from BC::Refined and implement method #match
.
The method should: - return self or nil on successful validation - return BC::ContractFailure instance by calling method #failure
and provide error text/symbol
require 'countries' # gem with data about countries class Country < BC::Refined def match return if ISO3166::Country.find_country_by_alpha2(context[:country_name] = value.to_s) failure(:unknown_country) end end
Also, you could improve the successful outcome by mapping VALID data to something more appropriate, for example you could normalize data. For that you need only implement #mapped
require 'countries' # gem with data about countries class Country < BC::Refined def match context[:country_string] = value.to_s context[:found_country] = ISO3166::Country.find_country_by_alpha2(context[:country_string]) return if context[:found_country] failure(:unknown_country) end def mapped context[:found_country].name end end case match = Country.call("CI") when Country match.unpack # => "Côte d'Ivoire" when BC::ContractFailure match.messages # => [:unknown_country] else raise # ... you know why end
Okay, we passed through single value validation. How about complex cases?
Imagine you want to validate response from some JSON API, let's write your first API client with refinement types together.
For this example we'll create RubygemsAPI client:
require 'net/http' module RubygemsAPI class Client ROOT = "https://rubygems.org/api/v1/gems/".freeze def self.get(path) uri = URI.parse(File.join(ROOT, path)) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.get(uri.request_uri).body end def self.gem(name) Validation.call get("#{name}.json") end end end
But what is the RubygemsAPI::Validation class?
“And Then” Composition (BC::Pipe class)¶ ↑
Our API client just reads a document from the Internet, which is why first we need to parse it as JSON and then extract something useful. This is where #and_then
method quite useful. It runs validation over first BC::Refined and only if the first validation was successful calls the other one. Otherwise we'll just receive BC::ContractFailure
, you know.
Our first challenge is to read Ruby gem info from the API, so we need two types: Json (for parsing) and Gem (for gem info)
module RubygemsAPI require 'json' class Json < BC::Refined def match # now it's easy to understand why we caught JSON::ParserError context[:response] = value.to_s context[:parsed] = ::JSON.parse(context[:response]) self rescue JSON::ParserError => ex context[:exception] = ex # now we could easily playaround with exception and reraise it failure(:invalid_json) end # so the next validation in the pipe will receive parsed response, not unparsed string def mapped context[:parsed] end end class GemInfo < BC::Refined # I chose some data that is interesing for me INFO_KEYS = %w(name downloads info authors version homepage_uri source_code_uri) def match # We have to make sure that result is a hash with appropriate keys is_a_project = value.is_a?(Hash) && (INFO_KEYS - value.keys).empty? return failure(:reponse_is_not_gem_info) unless is_a_project context[:gem_info] = value.slice(*INFO_KEYS) self end def mapped context[:gem_info] end end # Simple "and_then" composition will look like that: Validation = Json.and_then(GemInfo) end
Let's test our API client!
gem = RubygemsAPI::Client.gem("rack") # => #<RubygemAPI::GemInfo ...> gem.unpack # => {"name" => ..., "authors" => ...}
Nice! But wait, what if we misspelled gem name?
gem = RubygemsAPI::Client.gem("big-bada-bum") # => #<BC::ContractFailure ...> gem.messages # => [:invalid_json] # hmmm, wait... what? gem.context[:response] # => "This rubygem could not be found." # it is plain text. yes. :(
It would be great to show that original message to our user, but how?
“Or” Composition (BC::Sum class)¶ ↑
Actually, we could add another type in our validation using “Or” composition. Use it by calling #or_a
/ #or_an
method on your BC::Refined class. Let's try:
module RubygemsAPI # ... class PlainTextError < BC::Refined def match context[:response] = value.to_s # to avoid multiple parsing of response, we'll try to save it context[:parsed] = JSON.parse(context[:response]) failure(:non_plain_text_response) rescue JSON::ParserError self end def mapped context[:response] end end Validation = PlainTextError.or_a(Json.and_then(GemInfo)) end
Let's test our API client, again!
gem = RubygemsAPI::Client.gem("rack") # => #<RubygemAPI::GemInfo ...> gem.unpack # => {"name" => ..., "authors" => ...} # good, but how about not found case? gem = RubygemsAPI::Client.gem("big-bada-bum") # => #<RubygemAPI::PlainTextError ...> gem.unpack # => "This rubygem could not be found."
And of course we could use it in a case statement:
case gem = RubygemsAPI::Client.gem("rack") when GemInfo gem.unpack # show data to user when PlaintTextError {message: gem.unpack, status: 400} # wrap it into json response when BC::ContractFailure match.messages else raise # ... you know why end
It was a nice run!
So actually only one other scenario left to show.
Do you remember the Login type from the beginning? Let's try to implement simple registration form validation.
“And” Composition (BC::Tuple class)¶ ↑
If you'll try to represent complex data with refinement types the best tool is “and” composition or “product” of types. Sounds wierd?
But you actually work with that concept all the time. It's just a record or struct.
Let's write our registration form validation with a Struct:
RegistrationForm = Struct.new(:login, :password) do def self.call(login, password) # validation logic end end
So, the BloodContracts
version will look the same, except you only need to implement types for login and password:
module Registration class Email < ::BC::Refined REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i def match context[:email_input] = value.to_s return failure(:invalid_email) unless context[:email_input] =~ REGEX context[:email] = context[:email_input] self end end class Phone < ::BC::Refined REGEX = /\A(\+7|8)(9|8)\d{9}\z/i def match context[:phone_input] = value.to_s return failure(:invalid_phone) unless context[:phone_input] =~ REGEX context[:phone] = context[:phone_input] self end end class Ascii < ::BC::Refined REGEX = /^[[:ascii:]]+$/i def match context[:ascii_input] = value.to_s return failure(:not_ascii) unless context[:ascii_input] =~ REGEX context[:ascii_string] = context[:ascii_input] self end end # Create meta class as the Struct.new Form = BC::Tuple.new do # defines a reader and applies validation on `.match` call attribute :login, Email.or_a(Phone) attribute :password, Ascii # defines an attribute using the anonymous type class attribute :remember_me do def match value.to_s.in? ["checked", ""] end def mapped value.to_s == "checked" end end end end
Tuple can accept either a list of arguments or a hash:
Registration::Form.match(login, password) Registration::Form.match(login: login, password: password)
And the code that you'll put in your controller is something like that:
class RegistrationController < ActionController::Base def create case match = Registration::Form.match(params) when Registration::Form # login here is either Phone or Email # password here is always ASCII only string user = User.find_or_create!(login: match.login) do |user| user.password = match.password user.email = match.context[:email] user.phone = match.context[:phone] end render json: {code: 200, user_id: user.id, message: "User was successfully created!"} when BC::ContractFailure message = match.messages.map(&I18n.method(:t)).join("\n") render json: {code: 400, message: message} else Honeybadger.notify("Invalid BloodContracts usage", context: match.inspect) render json: {code: 500, message: "Unexpected contract behavior. Fix me ASAP"} end end end
Now, you're ready to write any kind of complex data validation with BloodContracts
What are the next steps?
Soon we'll announce blood_contracts-extended
and blood_contracts-monitoring
, which will help you monitor the data (what types and how often matches in your system) and even collect for you unique samples of the communication (up to the types that matched).
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 gemspec
, 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.
Contributing¶ ↑
Bug reports and pull requests are welcome on GitHub at github.com/sclinede/blood_contracts-core. 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.
License¶ ↑
The gem is available as open source under the terms of the MIT License.
Code of Conduct¶ ↑
Everyone interacting in the BloodContracts::Core
project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.