🚦 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
nil
to define optional checkers
- Using
- Data Structure Checkers
- List Schema
- Hash Schema
- Hash key/value Pairs Schema
- Registered Checkers
- Defining Interfaces
BCDD::Contract::Interface
BCDD::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::Contract
methods 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.0
However, 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) # Infinity
Let'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::Interface
to 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::Proxy
to 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-result
gem and uses thebcdd-contract
to define its contract. -
Design by Contract - Shows how the
bcdd-contract
can 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
end
Let's break down the example above.
- The
User::Repository
module includesBCDD::Contract::Interface
. - Defines the
Methods
module. It is mandatory, as these will be the methods to be implemented. - The
create
method is defined inside theMethod
s' module.- This method receives two arguments:
name
andemail
. - The arguments are checked using the
IsString
checker.- The
+
operator performs a strict check. An error will be raised if the value is invalid. Otherwise, the value will be returned.
- The
-
super
is called to invoke thecreate
method of the superclass. Which will be the class/object that includes/extends theUser::Repository
module. - The
output
is checked using pattern matching.- The
=>
operator performs strict checks. If the value is invalid, aNoMatchingPatternError
will be raised.
- The
- The
output
is 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
end
And 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
end
What 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) # true
Why 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
# ...
end
Access 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
end
How 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')
# false
Assertions/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::Contract
assertions.
⬆️ 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
end
In 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