🚦 BCDD::Contract
Reliable contract definition, data validation, and type checking for Ruby.
- Introduction
- Features
- Motivation
- Examples
- Installation
- Usage
- Contract Units
- Lambda Based
- Type Based
- Union Based
- Using
nilto define optional checkers
- Using
- Data Structure Checkers
- List Schema
- Hash Schema
- Hash key/value Pairs Schema
- Registered Checkers
- Defining Interfaces
BCDD::Contract::InterfaceBCDD::Contract::Proxy
- Assertions
- Contract Units
- Configuration
- Switchable features
- Non-switchable features
- Reference
- The Contract Checker API
.===.to_proc.invariant
- The Contract Checking API
- Unary operators
-
BCDD::Contractmethods BCDD::Contract::Assertions
- The Contract Checker API
- About
- Development
- Contributing
- License
- Code of Conduct
Introduction
bcdd-contract is a library for implementing contracts in Ruby. It provides abstractions to validate data structures, perform type checks, and define contracts inlined or through proxies.
Features
- Strict type checking.
- Value validation with error messages.
- Data structure validation: Hashes, Arrays, Sets.
- Interface mechanisms.
- Configurable features.
- Pattern matching integration.
- More Ruby and less DSL.
- Simple and easy to use.
Motivation
Due to the addition of pattern matching, Ruby now has an excellent tool for doing type checks.
def divide(a, b)
a => Float | Integer
b => Float | Integer
outcome = a / b => Float | Integer
outcome
end
divide('4', 2) # Integer === "4" does not return true (NoMatchingPatternError)
divide(4, '2') # Integer === "2" does not return true (NoMatchingPatternError)
divide(4, 2r) # Integer === (2/1) does not return true (NoMatchingPatternError)
divide(4, 2.0) # 2.0However, more is needed to implement contracts. Often, the object is of the expected type but does not have a valid state.
# Examples of floats that are undesirable (invalid state)
divide(0.0, 0.0) # NaN
divide(0.0, 1.0) # Infinity
divide(Float::NAN, 2) # NaN
divide(Float::INFINITY, 2) # InfinityLet's see how we can use bcdd-contract can be used to implement contracts that will work with and without pattern matching.
module FloatOrInt
is_finite = ->(val) { val.finite? or "%p must be finite" }
extend (BCDD::Contract[Float] & is_finite) | Integer
end
def divide(a, b)
a => FloatOrInt
b => FloatOrInt
outcome = a / b => FloatOrInt
outcome
end
divide('4', 2) # FloatOrInt === "4" does not return true (NoMatchingPatternError)
divide(4, '2') # FloatOrInt === "2" does not return true (NoMatchingPatternError)
divide(4, 2r) # FloatOrInt === (2/1) does not return true (NoMatchingPatternError)
divide(0.0, 0.0) # FloatOrInt === NaN does not return true (NoMatchingPatternError)
divide(0.0, 1.0) # FloatOrInt === Infinity does not return true (NoMatchingPatternError)
divide(Float::NAN, 2) # FloatOrInt === NaN does not return true (NoMatchingPatternError)
divide(Float::INFINITY, 2) # FloatOrInt === Infinity does not return true (NoMatchingPatternError)
divide(4, 2.0) # 2.0
# The contract can be used to validate values
FloatOrInt['1'].valid? # false
FloatOrInt['2'].invalid? # true
FloatOrInt['3'].errors # ['"3" must be a Float OR "3" must be a Integer']
FloatOrInt['4'].value # "4"
FloatOrInt['5'].value! # "5" must be a Float OR "4" must be a Integer (BCDD::Contract::Error)Although all of this, the idea of contracts goes far beyond type checking or value validation. They are a way to define an expected behavior (method's inputs and outputs) and ensure pre-conditions, post-conditions, and invariants (Design by Contract concepts).
It looks good? So, let's see what more bcdd-contract can do.
⬆️ back to top
Examples
Check the examples directory to see different applications of bcdd-contract.
Attention: Each example has its own README with more details.
-
Ports and Adapters - Implements the Ports and Adapters pattern. It uses
BCDD::Contract::Interfaceto provide an interface from the application's core to other layers. -
Anti-Corruption Layer - Implements the Anti-Corruption Layer pattern. It uses the
BCDD::Contract::Proxyto define an inteface for a set of adapters, which will be used to translate an external interface (vendors) to the application's core interface. -
Business Processes - Implements a business process using the
bcdd-resultgem and uses thebcdd-contractto define its contract. -
Design by Contract - Shows how the
bcdd-contractcan be used to establish pre-conditions, post-conditions, and invariants in a class.
⬆️ back to top
Installation
Add this line to your application's Gemfile:
gem 'bcdd-contract'And then execute:
$ bundle install
Or install it yourself as:
$ gem install bcdd-contract
And require it:
require 'bcdd/contract' # or require 'bcdd-contract'⬆️ back to top
Usage
Contract Units
A unit can be used to check any object, use it when you need to check the type of an object or validate its value.
Lambda Based
There are two ways to create a unit checker using a Ruby lambda.
The difference between them is the number of arguments that the lambda receive.
One argument
When the lambda receives only one argument, it will be considered an error when it returns a string. Otherwise, it will be valid.
# Using and, or keywords
BCDD::Contract[->(val) { val.empty? or "%p must be empty" }]
BCDD::Contract[->(val) { val.empty? and "%p must be filled" }]
# The same as above, but using if/unless + return
BCDD::Contract[->(val) { "%p must be empty" unless val.empty? }]
BCDD::Contract[->(val) { "%p must be filled" if val.empty? }]You can also use numbered parameters to make the code more concise.
BCDD::Contract[-> { _1.empty? or "%p must be empty" }]
BCDD::Contract[-> { _1.empty? and "%p must be filled" }]
BCDD::Contract[-> { "%p must be empty" unless _1.empty? }]
BCDD::Contract[-> { "%p must be filled" if _1.empty? }]Two arguments
When the lambda receives two arguments, the first will be the value to be checked, and the second will be an array of errors. If the value is invalid, the lambda must add an error message to the array.
MustBeFilled = BCDD::Contract[->(val, err) { err << "%p must be filled" if val.empty? }]
MustBeFilled[''].valid? # false
MustBeFilled[[]].valid? # false
MustBeFilled[{}].valid? # false
MustBeFilled['4'].valid? # true
MustBeFilled[[5]].valid? # true
MustBeFilled[{six: 6}].valid? # true
[] => MustBeFilled # MustBeFilled === [] does not return true (NoMatchingPatternError)
{} => MustBeFilled # MustBeFilled === {} does not return true (NoMatchingPatternError)
checking = MustBeFilled[[]]
checking.errors # ["[] must be filled"]Check out the Registered Contract Checkers section to see how to avoid duplication of checker definitions.
⬆️ back to top
Type Based
Pass a Ruby module or class to BCDD::Contract[] to create a type checker.
IsEnumerable = BCDD::Contract[Enumerable]
IsEnumerable[[]].valid? # true
IsEnumerable[{}].valid? # true
IsEnumerable[1].valid? # false
{} => IsEnumerable # nil
[] => IsEnumerable # nil
1 => IsEnumerable # IsEnumerable === 1 does not return true (NoMatchingPatternError)
checking = IsEnumerable[1]
checking.errors # ["1 must be a Enumerable"]Check out the Registered Contract Checkers section to see how to avoid duplication of checker definitions.
⬆️ back to top
Union Based
After creating a unit checker, you can use the methods | (OR) and & (AND) to create union/intersection checkers.
is_filled = -> { _1.empty? and "%p must be filled" }
FilledArrayOrHash = (BCDD::Contract[Array] | Hash) & is_filled
FilledArrayOrHash[[]].valid? # false
FilledArrayOrHash[{}].valid? # false
FilledArrayOrHash[['1']].valid? # true
FilledArrayOrHash[{one: '1'}].valid? # true
[] => FilledArrayOrHash # FilledArrayOrHash === [] does not return true (NoMatchingPatternError)
{} => FilledArrayOrHash # FilledArrayOrHash === {} does not return true (NoMatchingPatternError)
checking = FilledArrayOrHash[[]]
checking.errors # ["[] must be filled"]Check out the Registered Contract Checkers section to see how to avoid duplication of checker definitions.
⬆️ back to top
Using nil to define optional checkers
You can use nil to create optional contract checkers.
IsStringOrNil = BCDD::Contract[String] | nil⬆️ back to top
Data Structure Checkers
List Schema
Use an array to define a schema. Only one element is allowed. Use the union checker to allow multiple types.
If the element is not a checker, it will be transformed into one.
The checker only accept arrays and sets.
ListOfString = ::BCDD::Contract([String])
ListOfString[[]].valid? # false
ListOfString[{}].valid? # false
ListOfString[['1', '2', '3']].valid? # true
ListOfString[Set['1', '2', '3']].valid? # true
['1', '2', 3] => ListOfString # ListOfString === ["1", "2", 3] does not return true (NoMatchingPatternError)
Set['1', '2', 3] => ListOfString # ListOfString === #<Set: {"1", "2", 3}> does not return true (NoMatchingPatternError)
checking = ListOfString[[1, '2', 3]]
checking.errors
# [
# "0: 1 must be a String",
# "2: 3 must be a String"
# ]Check out the Registered Contract Checkers section to see how to avoid duplication of checker definitions.
⬆️ back to top
Hash Schema
Use a hash to define a schema. The keys will be used to match the keys, and the values will be transformed into checkers (if they are not). You can use any kind of checker, including other hash schemas.
PersonParams = ::BCDD::Contract[{
name: String,
age: Integer,
address: {
street: String,
number: Integer,
city: String,
state: String,
country: String
},
phone_numbers: ::BCDD::Contract([String])
}]
PersonParams[{}].valid? # => false
PersonParams[{
name: 'John',
age: 30,
address: {
street: 'Main Street',
number: 123,
city: 'New York',
state: 'NY',
country: 'USA'
},
phone_numbers: ['+1 555 123 4567']
}].valid? # => true
params_checking = PersonParams[{
name: 'John',
age: '30',
address: {
street: 'Main Street',
number: 123,
city: nil,
state: :NY,
country: 'USA'
},
phone_numbers: ['+1 555 123 4567']
}]
params_checking.errors
# {
# :age => ["\"30\" must be a Integer"],
# :address => {
# :city => ["is missing"],
# :state => [":NY must be a String"]
# }
# }Check out the Registered Contract Checkers section to see how to avoid duplication of checker definitions.
⬆️ back to top
Hash key/value Pairs Schema
Use a hash to define a schema. The key and value will be transformed into checkers (if they are not).
is_int_str = -> { _1.is_a?(String) && _1.match?(/\A\d+\z/) or "%p must be a Integer String" }
PlayerRankings = ::BCDD::Contract.pairs(is_int_str => { name: String, username: String })
PlayerRankings[{}].valid? # => false
PlayerRankings[{
'1' => { name: 'John', username: 'john' },
'2' => { name: 'Mary', username: 'mary' },
'3' => { name: 'Paul', username: 'paul' }
}].valid? # => true
checking = PlayerRankings[{
'1' => { name: :John, username: 'john' },
'two' => { name: 'Mary', username: 'mary' },
'3' => { name: 'Paul', username: 3 }
}]
checking.errors
# [
# "1: (name: :John must be a String)",
# "key: \"two\" must be a Integer String",
# "3: (username: 3 must be a String)"
# ]Check out the Registered Contract Checkers section to see how to avoid duplication of checker definitions.
⬆️ back to top
Registered Checkers
Sometimes you need to use the same checker in different places. To avoid code duplication, you can register a checker and use it later.
Use the BCDD::Contract.register method to give a name and register a checker.
You can register any kind of checker:
-
Unit
- Lambda based
- Type based
- Union based
-
Data Structure
- List schema
- Hash schema
- Hash key/value pairs schema
is_string = ::BCDD::Contract[::String]
is_filled = -> { _1.empty? and "%p must be filled" }
uuid_format = -> { _1.match?(/\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/) or "%p must be a valid UUID" }
email_format = -> { _1.match?(/\A[^@\s]+@[^@\s]+\z/) or "%p must be a valid email" }
::BCDD::Contract.register(
is_uuid: is_string & uuid_format,
is_email: is_string & email_format,
is_filled: is_filled
)To use them, use a symbol with BCDD::Contract[] or a method that transforms a value into a checker.
str_filled = ::BCDD::Contract[:is_str] & :is_filled
PersonParams = ::BCDD::Contract[{
uuid: :is_uuid,
name: str_filled,
email: :is_email,
tags: [str_filled]
}]You can use registered checkers with unions and intersections.
BCDD::Contract.register(
is_hash: Hash,
is_array: Array,
is_filled: -> { _1.empty? and "%p must be filled" }
)
filled_array_or_hash = (BCDD::Contract[:is_array] | :is_hash) & :is_filled⬆️ back to top
Defining Interfaces
BCDD::Contract::Interface
This feature allows the creation of a module that will be used as an interface.
It will check if the class that includes it or the object that extends it implements all the expected methods.
module User::Repository
include ::BCDD::Contract::Interface
module Methods
IsString = ::BCDD::Contract[String]
def create(name:, email:)
output = super(name: +IsString[name], email: +IsString[email])
output => ::User::Data[id: Integer, name: IsString, email: IsString]
output
end
end
endLet's break down the example above.
- The
User::Repositorymodule includesBCDD::Contract::Interface. - Defines the
Methodsmodule. It is mandatory, as these will be the methods to be implemented. - The
createmethod is defined inside theMethods' module.- This method receives two arguments:
nameandemail. - The arguments are checked using the
IsStringchecker.- The
+operator performs a strict check. An error will be raised if the value is invalid. Otherwise, the value will be returned.
- The
-
superis called to invoke thecreatemethod of the superclass. Which will be the class/object that includes/extends theUser::Repositorymodule. - The
outputis checked using pattern matching.- The
=>operator performs strict checks. If the value is invalid, aNoMatchingPatternErrorwill be raised.
- The
- The
outputis returned.
- This method receives two arguments:
Now, let's see how to use it in a class.
class User::Record::Repository
include User::Repository
def create(name:, email:)
record = Record.create(name:, email:)
::User::Data.new(id: record.id, name: record.name, email: record.email)
end
endAnd how to use it in a module with singleton methods.
module User::Record::Repository
extend User::Repository
def self.create(name:, email:)
record = Record.create(name:, email:)
::User::Data.new(id: record.id, name: record.name, email: record.email)
end
endWhat happend when an interface module is included/extended?
- An instance of the class will be a
User::Repository. - The module, class, object, that extended the interface will be a
User::Repository.
class User::Record::Repository
include User::Repository
end
module UserTest::RepositoryInMemory
extend User::Repository
# ...
end
User::Record::Repository.new.is_a?(User::Repository) # true
UserTest::RepositoryInMemory.is_a?(User::Repository) # trueWhy this is useful?
Use is_a? to ensure that the class/object implements the expected methods.
class User::Creation
def initialize(repository)
repository => User::Repository
@repository = repository
end
# ...
endAccess the Ports and Adapters example to see, test, and run something that uses the
BCDD::Contract::Interface
⬆️ back to top
BCDD::Contract::Proxy
This feature allows the creation of a class that will be used as a proxy for another objects.
The idea is to define an interface for the object that will be proxied.
Let's implement the example from the previous section using a proxy.
class User::Repository < BCDD::Contract::Proxy
IsString = ::BCDD::Contract[String]
def create(name:, email:)
output = object.create(name: +IsString[name], email: +IsString[email])
output => ::User::Data[id: Integer, name: IsString, email: IsString]
output
end
endHow to use it?
Inside the proxy you will use object to access the proxied object. This means the proxy must be initialized with an object. And the object must implement the methods defined in the proxy.
class User::Record::Repository
# ...
end
module UserTest::RepositoryInMemory
extend self
# ...
end
# The proxy must be initialized with an object that implements the expected methods
memory_repository = User::Repository.new(UserTest::RepositoryInMemory)
record_repository = User::Repository.new(User::Record::Repository.new)Access the Anti-Corruption Layer to see, test, and run something that uses the
BCDD::Contract::Proxy
⬆️ back to top
Assertions
Use the BCDD::Contract.assert method to check if a value is truthy or use the BCDD::Contract.refute method to check if a value is falsey.
Both methods always expect a value and a message. The third argument is optional and can be used to perform a more complex check.
If the value is falsey for an assertion or truthy for a refutation, an error will be raised with the message.
Assertions withouth a block
item_name1 = nil
item_name2 = 'Item 2'
BCDD::Contract.assert!(item_name1, 'item (%p) not found')
# item (nil) not found (BCDD::Contract::Error)
BCDD::Contract.assert!(item_name2, 'item (%p) not found')
# "Item 2"Refutations withouth a block
allowed_quantity = 10
BCDD::Contract.refute!(20 > allowed_quantity, 'quantity is greater than allowed')
# quantity is greater than allowed (BCDD::Contract::Error)
BCDD::Contract.refute!(5 > allowed_quantity, 'quantity is greater than allowed')
# falseAssertions/Refutations with a block
You can use a block to perform a more complex check. The value passed to assert/refute will be yielded to the block.
item_name = 'Item 1'
item_quantity = 10
# ---
quantity_to_remove = 11
BCDD::Contract.assert(item_name, 'item (%p) not enough quantity to remove') { quantity_to_remove <= item_quantity }
# item ("Item 1") not enough quantity to remove (BCDD::Contract::Error)
BCDD::Contract.refute(item_name, 'item (%p) not enough quantity to remove') { quantity_to_remove > item_quantity }
# item ("Item 1") not enough quantity to remove (BCDD::Contract::Error)
quantity_to_remove = 10
BCDD::Contract.assert(item_name, 'item (%p) not enough quantity to remove') { quantity_to_remove <= item_quantity }
# "Item 1"
BCDD::Contract.refute(item_name, 'item (%p) not enough quantity to remove') { quantity_to_remove > item_quantity }
# "Item 1"Access the Design by Contract to see, test, and run something that uses the
BCDD::Contractassertions.
⬆️ back to top
Configuration
By default, the BCDD::Contract enables all its features. You can disable them by setting the configuration.
Switchable features
BCDD::Contract.configuration do |config|
dev_or_test = ::Rails.env.local?
config.proxy_enabled = dev_or_test
config.interface_enabled = dev_or_test
config.assertions_enabled = dev_or_test
endIn the example above, the BCDD::Contract::Proxy, BCDD::Contract::Interface, and BCDD::Contract.assert/BCDD::Contract.refute will be disabled in production.
Non-switchable features
The following variants are always enabled. You cannot disable them through the configuration.
-
BCDD::Contract::Proxy::AlwaysEnabled. -
BCDD::Contract::Interface::AlwaysEnabled. -
BCDD::Contract.assert!. -
BCDD::Contract.refute!.
⬆️ back to top
Reference
The Contract Checker API
This section describes the common API for all contract checkers:
-
Unit
- Lambda based
- Type based
- Union based
-
Data Structure
- List schema
- Hash schema
- Hash key/value pairs schema
Let's the following contract checker to illustrate the API.
IsFilled = BCDD::Contract[-> { _1.empty? and "%p must be filled" }].===
You can use the === operator to check if a value is valid. This operator is also used by the case statement and pattern matching operators.
# ===
IsFilled === '' # false
IsFilled === [] # false
IsFilled === '1' # true
# case statement
case {}
when IsFilled
# ...
end
# pattern matching
case []
in IsFilled
# ...
end
'' in IsFilled # false
Set.new => IsFilled # is_filled === #<Set: {}> does not return true (NoMatchingPatternError)⬆️ back to top
.to_proc
You can use the to_proc method to transform a value into a checking object.
[
'',
[],
{}
].map(&IsFilled).all?(&:valid?) # false
[
'1',
'2',
'3'
].map(&IsFilled).all?(&:valid?) # true⬆️ back to top
.invariant
Use the invariant to perform an strict check before and after the block execution.
IsFilled.invariant([1]) { |numbers| numbers.pop }
# [] must be filled (BCDD::Contract::Error)Access the Design by Contract a better example of how to use
invariant.
⬆️ back to top
The Contract Checking API
This section describes the common API for all contract checking objects. Objects that are created by a contract checker.
IsFilled = BCDD::Contract[-> { _1.empty? and "%p must be filled" }]
checking = IsFilled['']
checking.valid? # false
checking.invalid? # true
checking.errors? # true
checking.errors # ['"" must be filled']
checking.errors_message # '"" must be filled'
checking.value # ""
+checking # "" must be filled (BCDD::Contract::Error)
!checking # "" must be filled (BCDD::Contract::Error)
checking.value! # "" must be filled (BCDD::Contract::Error)
checking.assert! # "" must be filled (BCDD::Contract::Error)
# ---
checking = IsFilled['John']
+checking # "John"
!checking # "John"
checking.value! # "John"
checking.assert! # "John"⬆️ back to top
Unary operators
You can use the unary operators + and ! to perform a strict check. If the value is invalid, an error will be raised. Otherwise, the value will be returned.
+IsFilled[''] # "" must be filled (BCDD::Contract::Error)
!IsFilled[''] # "" must be filled (BCDD::Contract::Error)
+IsFilled['John'] # "John"
!IsFilled['John'] # "John"⬆️ back to top
BCDD::Contract methods
BCDD::Contract[lambda] # returns a unit checker
BCDD::Contract[module] # returns a type checker
BCDD::Contract[<Array>] # returns a list schema checker
BCDD::Contract[<Hash>] # returns a hash schema checker
BCDD::Contract(<Object>) # alias for BCDD::Contract[<Object>]
BCDD::Contract.new(<Object>) # alias for BCDD::Contract[<Object>]
BCDD::Contract.to_proc # returns a proc that transforms a value into a checker
BCDD::Contract.pairs(<Hash>) # returns a hash key/value pairs schema checker
BCDD::Contract.schema(<Hash>) # returns a hash schema checker
BCDD::Contract.list(<Object>) # returns a list schema checker
BCDD::Contract.unit(<Object>) # returns a unit checker (lambda/type based)
BCDD::Contract.error!(<String>) # raises a BCDD::Contract::Error
BCDD::Contract.assert(value, message, &block) # raises a BCDD::Contract::Error if the value/block is falsey
BCDD::Contract.refute(value, message, &block) # raises a BCDD::Contract::Error if the value/block is truthy
BCDD::Contract.assert!(value, message, &block) # same as BCDD::Contract.assert but cannot be disabled
BCDD::Contract.refute!(value, message, &block) # same as BCDD::Contract.refute but cannot be disabled
# Produces a BCDD::Contract::Proxy class
BCDD::Contract.proxy do
# ...
end
# Produces a BCDD::Contract::Proxy::AlwaysEnabled class
BCDD::Contract.proxy(always_enabled: true) do
# ...
end⬆️ back to top
BCDD::Contract::Assertions
Use this module to include/extend the BCDD::Contract assertions (#assert/#assert! and #refute/#refute!).
The methods without bang (#assert and #refute) can be disabled through the assertions configuration.
class User::Creation
include BCDD::Contract::Assertions
def initialize(repository)
assert!(repository, '%p must be a User::Repository') { _1.repository.is_a?(User::Repository) }
@repository = repository
end
# ...
end⬆️ back to top
About
Rodrigo Serradura created this project. He is the B/CDD process/method creator and has already made similar gems like the u-case and kind. This gem can be used independently, but it also contains essential features that facilitate the adoption of B/CDD in code.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test 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 the created tag, and push the .gem file to rubygems.org.
⬆️ back to top
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/B-CDD/contract. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
⬆️ back to top
License
The gem is available as open source under the terms of the MIT License.
⬆️ back to top
Code of Conduct
Everyone interacting in the BCDD::Contract project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.
⬆️ back to top