Schemacop
This is the README for Schemacop version 2, which breaks backwards compatibility with version 1.
Schemacop validates ruby structures consisting of nested hashes and arrays against schema definitions described by a simple DSL.
Examples:
schema = Schema.new do
req :naming, :hash do
opt :first_name, :string
req :last_name, :string
end
opt! :age, :integer, min: 18
req? :password do
type :string, check: proc { |pw| pw.include?('*') }
type :integer
end
end
schema.validate!(
naming: { first_name: 'John',
last_name: 'Doe' },
age: 34,
password: 'my*pass'
)
schema2 = Schema.new do
req :description,
:string,
if: proc { |str| str.start_with?('Abstract: ') },
max: 35,
check: proc { |str| !str.end_with?('.') }
req :description, :string, min: 35
end
schema2.validate!(description: 'Abstract: a short description')
schema2.validate!(description: 'Since this is no abstract, we expect it to be longer.')
Installation
To install the Schemacop gem:
$ gem install schemacop
To install it using bundler
(recommended for any application), add it to your
Gemfile
:
gem 'schemacop'
Basics
Since there is no explicit typing in Ruby, it can be hard to make sure that a method is recieving exactly the right kind of data it needs. The idea of this gem is to define a schema at boot time that will validate the data being passed around at runtime. Those two steps look as follows:
At boot time:
my_schema = Schema.new do
# Your specification goes here
end
At runtime:
my_schema.validate!(
# Your data goes here
)
validate!
will fail if the data given to it does not match what was specified
in the schema.
Type lines vs. Field lines
Schemacop uses a DSL (domain-specific language) to let you describe your schemas. We distinguish between two kinds of identifiers:
Field Lines: We call a key-value pair (like the contents of a hash) a field. A field line typically starts with the keyword
req
(for a required field) oropt
(for an optional field).Type Lines: Those start with the keyword
type
and specify the data type to be accepted with a corresponding symbol (e.g.:integer
or:boolean
). You can have multiple Type Lines for a Field Line in order to indicate that the field's value can be of one of the specified types.
If you don't use any short forms, a schema definition would be something like this:
s = Schema.new do
type :integer
type :hash do
req 'name' do
type :boolean
end
end
end
The above schema would accept either an integer or a hash with exactly one field with key 'present' of type String and value of type Boolean (either TrueClass or FalseClass).
We will see Type and Field lines in more detail below.
validate
vs validate!
vs valid?
The method validate
will return a Collector
object that contains all
validation errors (if any), whereas validate!
will accumulate all violations
and finally throw an exception describing them.
For simply querying the validity of some data, use the methods valid?
or
invalid?
.
Schemacop's DSL
In this section, we will ignore short forms and explicitly write out everything.
Inside the block given at the schema instantiation (Schema.new do ... end
),
the following kinds of method calls are allowed (where the outermost must be a
Type Line):
Type Line
A Type Line always starts with the identifier type
and specifies a possible
data type for a given field (if inside a Field Line) or the given data structure
(if directly below the schema instantiation).
Type Lines are generally of the form
type :my_type, option_1: value_1, ..., option_n: value_n
where :my_type
is a supported symbol (see section Types below for
supported types).
General options
Some types support specific options that allow additional checks on the nature
of the data (such as the min
option for type :number
). The following options
are supported by all types:
Option if
This option takes a proc (or a lambda) as value. The proc will be called when checking whether or not the data being analyzed fits a certain type. The data is given to the proc, which has to return either true or false. If it returns true, the type of the given data is considered correct and the data will be validated if further options are given.
Note that the proc in if
will only get called if the type (:my_type
from
above) fits the data already. You can use the option if
in order to say: "Even
if the data is of type :my_type
, I consider it having the wrong type if my
proc returns false."
Consider a scenario in which you want to have the following rule set:
- Only integers may be given
- Odd integers must be no larger than 15
- No limitations for even integers
The corresponding schema would look as follows:
Schma.new do
type :integer, if: proc { |data| data.odd? }, max: 15
type :integer
end
Here, the first type line will only accept odd numbers and the option max: 15
provided by the :integer
validator will discard numbers higher than 15.
Since the first line only accepts odd numbers, it doesn't apply for even numbers
(due to the proc given to if
they are considered to be of the wrong type) and
control falls through to the second type line accepting any integer.
Option check
This option allows you to perform arbitrary custom checks for a given data type.
Just like if
, check
takes a proc or lambda as a value, but it runs after
the type checking, meaning that it only gets executed if the data has the right
type and the proc in if
(if any) has returned true.
The proc passed to the check
option is given the data being analyzed. It is to
return true if the data passes the custom check. If it returns false, Schemacop
considers the data to be invalid.
The following example illustrates the use of the option check
: Consider a
scenario in which you want the following rule set:
- Data must be of type String
- The string must be longer than 5 characters
- The second character must be an 'r'
The corresponding schema would look as follows:
Schema.new do
type :string, min: 5, check: proc { |data| data[1] == 'r'}
end
The above Type Line has type :string
and two options (min
and check
). The
option min
is supported by the :string
validator (covered later).
Field Line
Inside a Type Line of type :hash
, you may specify an arbitrary number of field
lines (one for each key-value pair you want to be in the hash).
Field Lines start with one of the following six identifiers: req
, req?
,
req!
, opt
, opt?
or opt!
:
The suffix
-!
means that the field must not be nil.The suffix
-?
means that the field may be nil.The prefix
req-
denotes a required field (validation fails if the given data hash doesn't define it).req
is a shorthand notation forreq!
(meaning that by default, a required field cannot be nil).The prefix
opt-
denotes an optional field.opt
is a shorthand notation foropt?
(meaning that by default, an optional field may be nil).
To summarize:
req
orreq!
: required and non-nilreq?
: required but may be nilopt
oropt?
: optional and may be nilopt!
: optional but non-nil
You then pass a block with a single or multiple Type Lines to the field.
Example: The following schema defines a hash that has a required non-nil field
of type String under the key :name
(of type Symbol) and an optional but
non-nil field of type Integer or Date under the key :age
.
Schema.new do
type :hash do
req :name do
type :string
end
opt! :age do
type :integer
type :object, classes: Date
end
end
end
You might find the notation cumbersome, and you'd be right to say so. Luckily there are plenty of short forms available which we will see below.
Handling hashes with indifferent access
Schemacop has special handling for objects of the class
ActiveSupport::HashWithIndifferentAccess
: You may specify the keys as symbols
or strings, and Schemacop will handle the conversion necessary for proper
validation internally. Note that if you define the same key as string and
symbol, it will throw a ValidationError
exception when asked to
validate a hash with indifferent access.
Thus, the following two schema definitions are equivalent when validating a hash with indifferent access:
Schema.new do
type :hash do
req :name do
type :string
end
end
end
Schema.new do
type :hash do
req 'name' do
type :string
end
end
end
Types
Types are defined via their validators, which is a class under validator/
.
Each validator is sourced by schemacop.rb
.
The following types are supported by Schemacop by default:
:boolean
accepts a Ruby TrueClass or FalseClass instance.:integer
accepts a Ruby Integer.- supported options:
min
,max
(lower / upper bound)
- supported options:
:float
accepts a Ruby Float.- supported options:
min
,max
(lower / upper bound)
- supported options:
:number
accepts a Ruby Integer or Float.- supported options:
min
,max
(lower / upper bound)
- supported options:
:string
accepts a Ruby String.- supported options:
min
,max
(bounds for string length)
- supported options:
:symbol
accepts a Ruby Symbol.:object
accepts an arbitrary Ruby object (any object if no option is given).
Supported options:
classes
: Ruby class (or an array of them) that will be the only recognized filters. Unlike other options, this one affects not the validation but the type recognition, meaning that you can have multiple Type Lines with differentclasses
option for the same field, each having its own validation (e.g. through the optioncheck
).strict
: Boolean option, defaults to true. If set to false, the validator also allows derived classes of those specified withclasses
.:array
accepts a Ruby Array.
supported options:
min
,max
(bounds for array size) andnil
: TODOaccepts a block with an arbitrary number of Type Lines.
TODO no lookahead for different arrays, see validator_array_test#test_multiple_arrays
:hash
accepts a Ruby Hash or anActiveSupport::HashWithIndifferentAccess
.
accepts a block with an arbitrary number of Field Lines.
:nil
: accepts a Ruby NilClass instance. If you want to allownil
as a value in a field, see above for the usage of the suffixes-!
and-?
for Field Lines.
All types support the options if
and check
(see the section about Type Lines
above).
Short forms
For convenience, the following short forms may be used (and combined if possible).
Passing a type to a Field Line or schema
Instead of adding a Type Line in the block of a Field Line, you can omit do
type ... end
and directly write the type after the key of the field.
Note that when using this short form, you may not give a block to the Field Line.
# Long form
req :name do
type :string, min: 2, max: 5
end
# Short form
req :name, :string, min: 2, max: 5
This means that the value under the key :name
of type Symbol must be a String
containing 2 to 5 characters.
The short form also works in the schema instantiation:
# Long form
Schema.new do
type :string, min: 2, max: 5
end
# Short form
Schema.new(:string, min: 2, max: 5)
This means that the data given to the schema must be a String that is between 2 and 5 characters long.
Passing multiple types at once
You can specify several types at once by putting them in an array.
Note that when using this short form, you may not give any options.
# Long form
opt! :age do
type :string
type :integer
type :boolean
end
# Short form
opt! :age do
type [:string, :integer, :boolean]
end
Combined with previous short form:
opt! :age, [:string, :integer, :boolean]
This also works in the schema instantiation:
Schema.new([:string, :integer, :boolean])
This means that the schema will validate any data of type String, Integer, TrueClass or FalseClass.
Omitting the Type Line in a Field Line
If you don't specify the type of a field, it will default to :object
with no
options, meaning that the field will accept any kind of data:
# Long form
req? :child do
type :object
end
# Short form
req? :child
Omitting the Type Line in schema instantiation
If you don't give a Type Line to a schema, it will accept data of type Hash. Therefore, if you validate Hashes only, you can omit the Type Line and directly write Field Lines in the schema instantiation:
# Long form
Schema.new do
type :hash do
req :name do
# ...
end
end
end
# Short form
Schema.new do
req :name do
# ...
end
end
Shortform for subtypes
In case of nested arrays, you can group all Type Lines to a single one.
Note that any options or block passed to the grouped Type Line will be given to the innermost (last) type.
# Long form
type :array do
type :integer, min: 3
end
# Short form
type :array, :integer, min: 3
A more complex example:
Long form:
Schema.new do
type :hash do
req 'nutrition' do
type :array do
type :array do
type :hash, check: proc { |h| h.member?(:food) || h.member?(:drink) } do
opt! :food do
type :object
end
opt! :drink do
type :object
end
end
end
end
end
end
end
Short form (with this short form others from above):
Schema.new do
req 'nutrition', :array, :array, :hash, check: proc { |h| h.member?(:food) || h.member?(:drink) } do
opt! :food
opt! :drink
end
end
This example accepts a hash with exactly one String key 'nutrition' with value
of type Array with children of type Array with children of type Hash in which at
least one of the Symbol keys :food
and :drink
(with any non-nil value type)
is present.
Exceptions
Schemacop will throw one of the following checked exceptions:
This exception is thrown when the given schema definition format is invalid.
This exception is thrown when the given data does not comply with the given schema definition.
Known limitations
Schemacop does not yet allow cyclic structures with infinite depth.
Schemacop aborts when it encounters an error. It is not able to collect a full list of multiple errors.
Schemacop is not made for validating complex causalities (i.e. field
a
needs to be given only if fieldb
is present).Schemacop does not yet support string regex matching.
Contributors
Thanks to Rubocop for great inspiration concerning their name and the structure of their README file. And special thanks to SubGit for their great open source licensing.
Copyright
Copyright (c) 2017 Sitrox. See LICENSE
for further details.