A library for defining and validating data structures.
A contract is a collection of constraints that validate an object and its properties. Each Stannum::Contract
holds a set of Stannum::Constraints
, each of which must match an object or the referenced property for that object to match the contract as a whole. Contracts also obey the Constraint interface, and can be used inside other contracts to compose complex or nested validations.
Contracts can be created by passing a block to Stannum::Contract.new
:
contract = Stannum::Contract.new do
constraint(type: 'examples.constraints.numeric') do |actual|
actual.is_a?(Numeric)
end
constraint(type: 'examples.constraints.integer') do |actual|
actual.is_a?(Integer)
end
constraint(type: 'examples.constraints.in_range') do |actual|
actual >= 0 && actual <= 10 rescue false
end
end
Like constraints, contracts are used to determine whether objects match the expected behavior.
contract.matches?(nil)
#=> false
contract.errors_for(nil).map(&:type)
#=> ['examples.constraints.numeric', 'examples.constraints.integer', 'examples.constraints.in_range']
contract.matches?(99.0)
#=> false
contract.errors_for(99.0).map(&:type)
#=> ['examples.constraints.integer', 'examples.constraints.in_range']
contract.matches?(99)
#=> false
contract.errors_for(99).map(&:type)
#=> ['examples.constraints.in_range']
contract.matches?(5)
#=> true
As you can see, the contract matches the object against each of its constraints. If any of the constraints fail to match the object, then the contract also does not match. Finally, the errors from each failing constraint are aggregated together.
Like a constraint, a contract can perform a negated match. Whereas an object matches the contract if all of the constraints match the object, the object will pass a negated match if none of the constraints match the object.
contract = Stannum::Contract.new do
constraint(type: 'examples.constraints.color') do |hsh|
hsh[:color] == 'red'
end
constraint(type: 'examples.constraints.shape') do |hsh|
hsh[:color] == 'circle'
end
end
contract.matches?({ color: 'red', shape: 'circle' })
#=> true
contract.does_not_match?({ color: 'red', shape: 'circle' })
#=> false
contract.errors_for({ color: 'red', shape: 'square' }).map(&:type)
#=> ['examples.constraints.color', 'examples.constraints.shape']
contract.matches?({ color: 'red', shape: 'square' })
#=> false
contract.does_not_match?({ color: 'red', shape: 'square' })
#=> false
contract.errors_for({ color: 'red', shape: 'square' }).map(&:type)
#=> ['examples.constraints.color']
contract.matches?({ color: 'blue', shape: 'square'})
#=> false
contract.does_not_match?({ color: 'blue', shape: 'square'})
#=> true
Note that for an object that partially matches the contract, both #matches?
and #does_not_match?
methods will return false. If you want to check whether any of the constraints do not match the object, use the #matches?
method and apply the !
boolean negation operator (or switch from an if
to an unless
).
You can also add constraints to an existing contract using the #add_constraint
method.
constraint = Stannum::Constraint.new(type: 'examples.constraints.even') do |actual|
actual.respond_to?(:even) && actual.even?
end
contract.add_constraint(constraint)
#=> true
The #add_constraint
method returns the contract, so you can chain multiple #add_constraint
calls together.
Constraints can also define constraints on the properties of the matched object. This is a powerful feature for defining validations on objects and nested data structures. To define a property constraint, use the property
macro in a contract constructor block, or use the #add_property_constraint
method on an existing contract.
gadget_contract = Stannum::Contract.new do
property :name, Stannum::Constraints::Presence.new
property :name, Stannum::Constraints::Types::StringType.new
property(:size, type: 'examples.constraints.size') do |size|
%w[small medium large].include?(size)
end
property :manufacturer, Stannum::Contract.new do
constraint Stannum::Constraints::Presence.new
property :address, Stannum::Constraints::Presence.new
end
end
There’s a lot going on here, so let’s break it down. First, we’re defining constraints on the properties of the object, rather than on the object as a whole. In particular, note that we’re setting multiple constraints on the :name
property - an object will only match the contract if it’s #name
matches both of those constraints.
We’re also using some pre-defined constraints, rather than having to start from scratch. The Presence
constraint validates that an object is not nil
and not #empty?
, while the Types::StringType
constraint validates that the object is an instance of String
. For a full list of pre-defined constraints, see Built-In Constraints and Contracts, below. You can also define your own constraint classes and reference them in your contracts.
Finally, note that the constraint for the :manufacturer
property is itself a contract. We are asserting that the actual object has a non-nil
#manufacturer
property and that the manufacturer’s #address
is also non-nil
(and not #empty?
).
gadget = Gadget.new(manufacturer: Manufacturer.new)
gadget_contract.matches?(gadget)
#=> false
gadget_contract.errors_for(gadget).map { |err| [err.path, err.type] }
#=> [
# [%i[name], 'stannum.constraints.absent'],
# [%i[name], 'stannum.constraints.is_not_type'],
# [%i[size], 'examples.constraints.size'],
# [%i[manufacturer address], 'stannum.constraints.absent']
# ]
We’ve established that each error has a #type
, which identifies which type of constraint failed to match the object. Here, we can see that each error also has a #path
property, which represents the relative path of the property from the original matched object. For example, errors on the gadget.name
property will have a path of %i[name]
, while the error on the gadget.manufacturer.address
will have a path of %i[manufacturer address]
. A constraint without a property, i.e. on the matched object itself, will have a path of []
, an empty string.
The errors for a property or nested contract can also be accessed using the #[]
operator or the #dig
method.
gadget_contract.errors_for(gadget)[:manufacturer].map { |err| [err.path, err.type] }
#=> [[%i[address], 'stannum.constraints.absent']]
gadget_contract.errors_for(gadget).dig(:manufacturer, :address).map { |err| [err.path, err.type] }
#=> [[[], 'stannum.constraints.absent']]
Be careful when defining property constraints on a contract that might be matched against nil
or an unknown object type - Ruby will raise a NoMethodError
when trying to access the property. To avoid this, you can add a sanity constraint (see below) to ensure that the contract only validates the expected type of object.
In some cases, before running through the full set of constraints in a contract, we want to run a quick sanity check to make sure the contract is even applicable to the object. By adding sanity: true
when defining the constraint, you can mark a constraint as a sanity check.
contract = Stannum::Contract.new do
constraint(type: 'examples.constraints.nonzero') do |actual|
actual != 0
end
end
contract.add_constraint(
Stannum::Constraints::Types::IntegerType.new,
type: 'examples.constraints.numeric',
sanity: true
)
When matching an object, all of a contract’s sanity constraints will be evaluated first. The remaining constraints will be matched against the object only if all of the sanity constraints match the object. This can be especially important if some of the constraints return nonsensical results or even raise exceptions when given an invalid object.
contract.matches?(nil)
#=> false
contract.errors_for(nil).map(&:type)
#=> ['examples.constraints.numeric']
contract.matches?(0)
#=> false
contract.errors_for(0).map(&:type)
#=> ['examples.constraints.nonzero']
contract.matches?(1)
#=> true
Likewise, when performing a negated match, the sanity constraints will be evaluated first, and the remaining constraints will be evaluated only if all of the sanity constraints match.
Stannum provides two mechanisms for composing contracts together. Each contract is a constraint, and so can be added to another contract (with or without a property or scope). This allows you to create and reuse validation logic simply by adding a contract as a constraint:
named_contract = Stannum::Contract.new do
property :name, Stannum::Constraints::Presence.new
end
widget_contract = Stannum::Contract.new do
constraint(Stannum::Constraints::Type.new(Widget))
constraint(named_contract)
end
widget = Widget.new
widget_contract.matches?(Widget.new)
#=> false
widget_contract.matches?(Widget.new(name: 'Whirlygig'))
#=> true
The second mechanism is contract concatenation. Under the hood, concatenation directly pulls in the constraints from a concatenated contract, rather than evaluating that contract on its own. This can be likened to inheriting methods from a superclass or an included Module.
gadget_contract = Stannum::Contract.new do
constraint(Stannum::Constraints::Type.new(Gadget))
concat(named_contract)
end
Using concatenation, you have finer control over the constraints that are added to the contract. Specifically, when defining a contract you can mark certain constraints as excluded from concatenation by adding the concatenatable: false
keyword to #add_constraint
. As an example, this can be useful if you want to inherit constraints about the properties of an object, but not potentially conflicting constraints about the object’s type.
For most use cases, defining a custom contract subclass will involve adding default constraints for the contact. Stannum provides two easy methods for doing so. First, you can leverage the default behavior by passing a block to super
in the contract constructor. This will allow you to take advantage of the constraint
, property
, and other macros.
class GizmoContract
def initialize(**_options)
super do
constraint Stannum::Constraints::Type.new(Gizmo), sanity: true
property :complexity, Stannum::Constraints::Presence.new
end
end
end
As an alternative, Stannum::Contract
defines a private #define_constraints
method that is used to initialize any constraints.
class WhirlygigContract
private
def define_constraints
super
constraint Stannum::Constraints::Type.new(Whirlygig), sanity: true
property :rotation_speed, Stannum::Constraints::Types::FloatType.new
end
end
By default, a Stannum::Contract
accesses an object’s properties as method calls, using the .
dot notation. When validating Array
s and Hash
es, this approach is less useful. Therefore, Stannum provides special contracts for operating on data structures.
ArrayContract
validates sequential data.HashContract
validates key-value data.ParametersContract
validates parameters for a method call.A full list can be found in the Reference Documentation in the Contracts namespace.
An ArrayContract is used for validating sequential data, using the #[]
method to access indexed values.
class BaseballContract < Stannum::Contracts::ArrayContract
def initialize
super do
item { |actual| actual == 'Who' }
item { |actual| actual == 'What' }
item { |actual| actual == "I Don't Know" }
end
end
end
contract = BaseballContract.new
contract.matches?(nil)
#=> false
contract.errors_for(nil).map { |err| [err.path, err.type] }
#=> [[[], 'stannum.constraints.is_not_type']]
array = %w[Who What]
contract.matches?(array)
#=> false
contract.errors_for(array).map { |err| [err.path, err.type] }
#=> [[[2], 'stannum.constraints.invalid']]
array = ['Who', 'What', "I Don't Know"]
contract.matches?(array)
#=> true
array = ['Who', 'What', "I Don't Know", 'Tomorrow']
contract.matches?(array)
#=> false
contract.errors_for(array).map { |err| [err.path, err.type] }
#=> [[[3], 'stannum.constraints.tuples.extra_items']]
Here, we are defining an ArrayContract using the #item
macro, which defines an item constraint for each successive item in the array. We can also define a property constraint using the #property
macro, using an Integer as the property to validate. This would allow us to add multiple constraints for the value at a given index, although the recommended approach is to use a nested contract.
When matching an object, the contract first validates that the object is an instance of Array
. If not, it will immedidately fail matching and the remaining constraints will not be matched against the object. If the object is an an array, then the contract checks each of the defined constraints against the value of the array at that index.
Finally, the constraint checks for the highest index expected by an item constraint. If the array contains additional items after this index, those items will fail with a type of "extra_items"
. To allow additional items instead, pass allow_extra_items: true
to the ArrayContract
constructor.
contract = BaseballContract.new(allow_extra_items: true)
contract.matches?(['Who', 'What', "I Don't Know", 'Tomorrow'])
#=> true
An ArrayContract
will first validate that the object is an instance of Array
. For validating Array-like objects that access indexed data using the #[]
method, you can instead use a TupleContract.
A HashContract is used for validating key-value data, using the #[]
method to access values by key.
class ResponseContract < Stannum::Contracts::HashContract
def initialize
super do
key :status, Stannum::Constraints::Types::IntegerType.new
key :json,
Stannum::Contracts::HashContract.new(allow_extra_keys: true) do
key :ok, Stannum::Constraints::Boolean.new
end
key :signature, Stannum::Constraints::Presence.new
end
end
end
contract = ResponseContract.new
contract.matches?(nil)
#=> false
contract.errors_for(nil).map { |err| [err.path, err.type] }
#=> [[[], 'stannum.constraints.is_not_type']]
response = { status: 500, json: {} }
contract.matches?(response)
#=> false
contract.errors_for(response).map { |err| [err.path, err.type] }
#=> [
# %i[json ok], 'stannum.constraints.is_not_boolean'],
# %i[signature], 'stannum.constraints.absent'
# ]
response = { status: 200, json: { ok: true }, signature: '12345' }
contract.matches?(response)
#=> true
response = { status: 200, json: { ok: true }, signature: '12345', role: 'admin' }
#=> false
contract.errors_for(response).map { |err| [err.path, err.type] }
#=> [[%i[role], 'stannum.constraints.hashes.extra_keys']]
We define a HashContract using the #key
macro, which defines a key-value constraint for the specified value in the hash. When validating a Hash, the value at each key must match the given constraint. The contract will also fail if there are additional keys without a corresponding constraint. To allow additional keys instead, pass allow_extra_keys: true
to the HashContract
constructor.
contract = ResponseContract.new(allow_extra_keys: true)
response = { status: 200, json: { ok: true }, signature: '12345', role: 'admin' }
contract.matches?(response)
#=> true
A HashContract
will first validate that the object is an instance of Hash
. For validating Hash-like objects that access key-value data using the #[]
method, you can instead use a MapContract.
A ParametersContract is used for validating parameters for a method call.
class AuthorizationParameters < Stannum::Contracts::ParametersContract
def initialize
super do
argument :action, Symbol
argument :record_class, Class, default: true
keyword :role, String, default: true
keyword :user, Stannum::Constraints::Type.new(User)
end
end
end
contract = AuthorizationParameters.new
parameters = {
arguments: [:create, Article],
keywords: {},
block: nil
}
contract.matches?(parameters)
#=> false
errors = contract.errors_for(parameters)
errors[:arguments].empty?
#=> true
errors[:keywords].empty?
#=> false
Each ParametersContract
defines .argument
, .keyword
, and .block
class methods to define the expected method parameters. The contract will automatically convert a Class into the corresponding Type constraint.
.argument
class method defines an expected argument. Like the .item
class method in an ArrayContract
(see Array Contracts, above), each call to .argument
will reference the next positional argument..keyword
class method defines an expected keyword..block
class method can accept either a constraint, or true
or false
. If given a constraint, the block passed to the method will be matched against the constraint. If given true
, then the contract will match against any block and will fail if the method is not called with a block; likewise, if given false
, the contract will match if no block is given and fail if the method is called with a block.Because of Ruby’s semantics around arguments and keywords with default values, the :default
keyword has a special meaning for parameters contracts. If .argument
or .keyword
is called with the :default
keyword, it indicates that that parameter has a default value in the method definition. If that argument or keyword is omitted, the parameters will still match the contract. However, an explicit value of nil
will still fail unless nil
is a valid value for the relevant constraint.
ParametersContract
also has support for variadic arguments and keywords.
class RecipeParameters < Stannum::Contracts::ParametersContract
def initialize
super do
arguments :tools, String
keywords :ingredients, Stannum::Contracts::TupleContract.new do
item Stannum::Constraints::Type.new(String),
property_name: :amount
item Stannum::Constraints::Type.new(String, optional: true),
property_name: :unit
end
block true
end
end
end
The .arguments
class method creates a constraint that matches against any arguments without an explicit .argument
expectation. Likewise, the .keywords
class method creates a constraint that matches against any keywords without an explicit .keyword
expectation - each key-value pair is converted to an Array with two items.
Back to Documentation