Contr
Minimalistic contracts in plain Ruby.
Installation
Install the gem and add to Gemfile:
bundle add contr
Or install it manually:
gem install contr
Terminology
Contract consists of rules of 2 types:
- guarantees - the ones that should be valid
- expectations - the ones that could be valid
Contract is called matched when:
- all guarantees are matched (if present)
- at least one expectation is matched (if present)
Rule is matched when it returns truthy value.
Rule is not matched when it returns falsy value (nil
, false
) or raises an error.
Contract is triggered after operation under guard is successfully executed.
Usage
Example of basic contract:
class SumContract < Contr::Act
guarantee :result_is_positive_float do |(_), result|
result.is_a?(Float) && result > 0
end
guarantee :args_are_numbers do |args|
args.all?(Numeric)
end
expect :arg_1_is_float do |(arg_1, _)|
arg_1.is_a?(Float)
end
expect :arg_2_is_float do |(_, arg_2)|
arg_2.is_a?(Float)
end
end
args = [1, 2.0]
contract = SumContract.new
contract.check(*args) { args.inject(:+) }
Contract check can be run in 2 modes: sync
and async
.
Sync
In sync
mode rules are executed sequentially in the same thread with the operation.
If contract matched - operation result is returned afterwards:
contract.check(*args) { 1 + 1 }
If contract failed - contract state is dumped via Sampler, logged via Logger and match error is raised:
contract.check(*args) { 1 + 1 }
If operation raises an error it will be propagated right away, without triggering the contract itself:
contract.check(*args) { raise StandardError, "some error" }
Async
In async
mode rules are executed in a separate thread. Operation result is returned immediately regardless of contract match status:
contract.check_async(*args) { 1 + 1 }
If operation raises an error it will be propagated right away, without triggering the contract itself:
contract.check_async(*args) { raise StandardError, "some error" }
Each contract instance can work with 2 dedicated thread pools:
main
- to execute contract checks asynchronously (always present)rules
- to execute rules asynchronously (not set by default)
There are couple of predefined pool primitives that can be used:
Contr::Async::Pool::Fixed.new
Contr::Async::Pool::Fixed.new(max_threads: 9000)
Contr::Async::Pool::GlobalIO.new
Default contract async
config looks like this:
class SomeContract < Contr::Act
async pools: {
main: Contr::Async::Pool::Fixed.new,
rules: nil
}
end
To enable asynchronous execution of rules:
class SomeContract < Contr::Act
async pools: {
rules: Contr::Async::Pool::GlobalIO.new
}
end
[!NOTE]
Asynchronous execution of rules forces to check them all - not the smallest scope possible as with the sequential one. Make sure that potential extra calls to DB/network are OK (if they have place).
It's also possible to define custom pool:
class CustomPool < Contr::Async::Pool::Base
def initialize(*some_args)
end
def create_executor
Concurrent::ThreadPoolExecutor.new(
min_threads: 0,
max_threads: 1234
)
end
end
class SomeContract < Contr::Act
async pools: {
main: CustomPool.new(*some_args)
}
end
Comparison of different pools configurations can be checked in Benchmarks section.
Sampler
Default sampler creates marshalized dumps of contract state in specified folder with sampling period frequency:
{
ts: "2024-02-26T14:16:28.044Z",
contract_name: "SumContract",
failed_rules: [
{type: :expectation, name: :arg_1_is_float, status: :failed},
{type: :expectation, name: :arg_2_check_that_raises, status: :unexpected_error, error: error_instance}
],
ok_rules: [
{type: :guarantee, name: :result_is_positive_float, status: :ok},
{type: :guarantee, name: :args_are_numbers, status: :ok}
],
async: false,
args: [1, 2.0],
result: 3.0
}
ConfiguredSampler = Contr::Sampler::Default.new(
folder: "/tmp/contract_dumps",
path_template: "%<contract_name>s_%<period_id>i.bin",
period: 3600
)
class SomeContract < Contr::Act
sampler ConfiguredSampler
end
Sampler is enabled by default:
class SomeContract < Contr::Act
end
SomeContract.new.sampler
It's possible to define custom sampler and use it instead:
class CustomSampler < Contr::Sampler::Base
def initialize(*some_args)
end
def sample!(state)
end
end
class SomeContract < Contr::Act
sampler CustomSampler.new(*some_args)
end
As well as to disable sampler completely:
class SomeContract < Contr::Act
sampler nil
end
Default sampler also provides a helper method to read created dumps:
contract.sampler
contract.sampler.read(path: "/tmp/contracts/SomeContract/474750.dump")
contract.sampler.read(contract_name: "SomeContract", period_id: "474750")
Logger
Default logger logs contract state to specified stream in JSON format. State structure is the same as in sampler plus additional tag
field:
{
**sampler_state,
tag: "contract-failed"
}
ConfiguredLogger = Contr::Logger::Default.new(
stream: $stderr,
log_level: :warn,
tag: "shit-happened"
)
class SomeContract < Contr::Act
logger ConfiguredLogger
end
Logger is enabled by default:
class SomeContract < Contr::Act
end
SomeContract.new.logger
It's possible to define custom logger in the same manner as with sampler:
class CustomLogger < Contr::Sampler::Base
def initialize(*some_args)
end
def log(state)
end
end
class SomeContract < Contr::Act
logger CustomLogger.new(*some_args)
end
As well as to disable logger completely:
class SomeContract < Contr::Act
logger nil
end
Configuration
Contract can be configured using arguments passed to .new
method:
class SomeContract < Contr::Act
end
contract = SomeContract.new(
async: {pools: {main: OtherPool.new, rules: AnotherPool.new}},
sampler: CustomSampler.new,
logger: CustomLogger.new
)
contract.main_pool
contract.rules_pool
contract.sampler
contract.logger
Contracts can be deeply inherited:
class SomeContract < Contr::Act
guarantee :check_1 do
end
expect :check_2 do
end
end
class OtherContract < SomeContract
async pools: {rules: Contr::Async::Pool::GlobalIO.new}
sampler CustomSampler.new
guarantee :check_3 do
end
end
class AnotherContract < OtherContract
async pools: {main: Contr::Async::Pool::GlobalIO.new}
logger nil
expect :check_4 do
end
end
Rule block arguments can be accessed in different ways:
class SomeContract < Contr::Act
guarantee :all_args_used do |(arg_1, arg_2), result|
arg_1
arg_2
result
end
guarantee :result_ignored do |(arg_1, arg_2)|
arg_1
arg_2
end
guarantee :check_args_ignored do |(_), result|
result
end
guarantee :args_not_used do
end
end
SomeContract.new.check(1, 2) { 1 + 2 }
Having access to result
can be really useful in contracts where operation produces a data that must be used inside the rules:
class PostCreationContract < Contr::Act
guarantee :verified_via_api do |(user_id), result|
post_id = result["id"]
API.post_exists?(user_id, post_id)
end
end
contract = PostCreationContract.new
contract.check(user_id) { API.create_post(*some_args) }
Contract instances are fully isolated from check invocations and can be safely cached:
module Contracts
PostRemoval = PostRemovalContract.new
PostRemovalNoLogger = PostRemovalContract.new(logger: nil)
end
posts.each do |post|
Contracts::PostRemovalNoLogger.check_async(*args) { delete_post(post) }
end
Examples
Examples can be found here.
Benchmarks
Comparison of different pool configs for I/O blocking and CPU intensive tasks can be found in benchmarks folder.
TODO
Development
bin/setup
bin/console
rake spec
rake rubocop
rake rubocop:md
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/ocvit/contr.
License
The gem is available as open source under the terms of the MIT License.
Credits