Duck Typing is pretty fantastic, but sometimes you need to be sure your inputs match what you expect.
REST APIs are a great example: you've defined the structure of your endpoints and the parameters you expect, down to the datatype. Now you could throw in a bunch of conditionals to check input parameters, but that gets out of hand quickly and is a nightmare to maintain.
What if you could define how an object should look, and check if you're getting back what you expect.
So instead of:
def search
unless params[:user].present? and params[:user][:name].present? and params[:user][:age].present? and params[:user][:age].is_a? Integer and params[:user][:age] > 0 # ... you get the point
head :bad_request and return
end
# After that mouthful, actually do something
end
You have:
module UserSchemas
def search
DuckHunt::Schemas::HashSchema.define :strict_mode => true do |user|
user.string "name", :required => true, :allow_nil => false
user.integer "age", :required => true, :allow_nil => false, :greater_than => 0
user.nested_hash "address", :required => true do |address|
address.string "state", :required => true
address.string "city", :required => false
end
end
end
end
class UserAPI
def search
head :bad_request and return unless UserSchemas.search.validate?(params[:user])
# the rest of your API call
end
end
It's also blazing fast, since it's dependency-free and deals with plain Ruby objects.
Just add the following to your Gemfile:
gem 'duck-hunt'
Ruby 1.8.7+
That's it. This library was designed to be dependency-free, built entirely with Ruby. There are some parts that have been borrowed from activesupport, but they're baked into the library.
A schema has multiple properties, which can have multiple validators. That sounds complex, but the syntax is designed to help you understand exactly how an object's defined.
Schemas are the top-level structure of the object. There are two types of Schemas: a Hash and an Array. These are the two types of objects you'll be checking.
You define a schema using the following syntax:
DuckHunt::Schemas::HashSchema.define do |hash|
# define hash key/valaue pairings here
end
# OR
DuckHunt::Schemas::ArraySchema.define do |array|
# define array entry properties here
end
define
returns an instance of the schema defined by the block you gave it. Calling validate?
on this instance with a ruby object validates the object against the and returns a boolean. If the object is not valid, the errors
method returns the errors explaining what went wrong
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.string "name"
end
schema.validate?(:name => "hey")
#=> true
schema.validate?(:name => 12)
#=> false
schema.errors
#=> {"name"=>["wrong type"]}
When using Duck Hunt to validate hashes, you're asking "does this hash have the following keys, and is the key's value what I expect?"
The basic syntax for defining a hash schema is:
DuckHunt::Schemas::HashSchema.define do |x|
#x.key_type "key_name", :any_other => 1, :validators => true
x.string "name", :matches => /\w+\s\w+/
x.string "title", :required => false
end
Any property added to the hash schema is required by default. You can change that behavior by adding :required => false
to the property definition. For clarity, I recommend always setting the :required
option.
A property can only be defined in a schema once. Otherwise, a DuckHunt:::PropertyAlreadyDefined
exception is thrown.
There are two types of validation for hash schemas: Strict and Relaxed. The validation type is controlled by the :strict_mode
option in the define
method.
Strict Validation is the default type of validation for a Hash Schema. It validates that the object does not have any keys that are not defined in the schema:
strict_schema = DuckHunt::Schemas::HashSchema.define, :strict_mode => true do |x|
x.string "name"
end
strict_schema.validate?({:name => "Jane"})
#=> true
strict_schema.validate?({:name => "Jane", :age => 21})
#=> false
strict_schema.errors
#=> {"base"=>["has properties not defined in schema"]}
Relaxed validation does not care if the object has keys that are not defined in the schema:
relaxed_schema = DuckHunt::Schemas::HashSchema.define, :strict_mode => false do |x|
x.string "name"
end
relaxed_schema.validate?({:name => "Jane"})
#=> true
relaxed_schema.validate?({:name => "Jane", :age => 21})
#=> true
If you don't care whether the object is nil
or not, you can set :allow_nil => true
in the define
method:
nil_schema = DuckHunt::Schemas::HashSchema.define, :allow_nil => false do |x|
x.string "name"
end
nil_schema.validate?({:name => "Jane"})
#=> true
nil_schema.validate?(nil)
#=> true
When using Duck Hunt to validate hashes, you're asking "does this array contain the values that I expect?" There are two types of Array Schemas, each with vastly different definitions and behaviors.
A single type array means that every item in the array has the same type and matches the same properties.
You define a single type array by adding a single property in the schema definition:
schema = DuckHunt::Schemas::ArraySchema.define do |x|
x.integer
end
schema.validate?([1,2,3])
#=> true
schema.validate?([1,"whoops",3])
#=> false
schema.errors
#=> {"1"=>["wrong type"]}
You can also set a minmum size for the array, a maximum size, or both!
minimum_schema = DuckHunt::Schemas::ArraySchema.define :min_size => 2 do |x|
x.integer
end
minimum_schema.validate?([1,2])
#=> true
minimum_schema.validate?([1])
#=> false
minimum_schema.errors
#=> {"base" => ["expected at least 2 item(s) but got 1 item(s)"]}
max_schema = DuckHunt::Schemas::ArraySchema.define :max_size => 2 do |x|
x.integer
end
max_schema.validate?([1,2])
#=> true
max_schema.validate?([1,2,3])
#=> false
max_schema.errors
#=> {"base" => ["expected at most 2 item(s) but got 3 item(s)"]}
max_schema = DuckHunt::Schemas::ArraySchema.define :min_size => 2 :max_size => 3 do |x|
x.integer
end
max_schema.validate?([1])
#=> false
max_schema.errors
#=> {"base" => ["expected at least 2 item(s) but got 1 item(s)"]}
max_schema.validate?([1,2])
#=> true
max_schema.validate?([1,2,3])
#=> true
max_schema.validate?([1,2,3,4])
#=> false
max_schema.errors
#=> {"base" => ["expected at most 3 item(s) but got 4 item(s)"]}
A tuple array is an ordered array that can have mixed types. It expects a defined number of required items, and may have optional items at the end of the array. All items in the array must match the type defined for that index.
To define the required items for a tuple array, you call items
in the define
block:
tuple_schema = DuckHunt::Schemas::ArraySchema.define do |x|
x.items do |s|
s.integer
s.string
end
end
tuple_schema.validate?([1,"hello"])
#=> true
tuple_schema.validate?([1])
#=> false
tuple_schema.errors
#=> { "base" => "expected at least 2 item(s) but got 1 item(s)"}
tuple_schema.validate?([1,"hello", 3])
#=> false
tuple_schema.errors
#=> { "base" => "expected at most 2 item(s) but got 3 item(s)"}
tuple_schema.validate?([1,2])
#=> false
tuple_schema.errors
#=> { "1" => "wrong type" }
Likewise, to define to optional itmes for a tuple array, you call optional_items
in the define
block. Note that the object does not have to have every optional item.
tuple_schema = DuckHunt::Schemas::ArraySchema.define do |x|
x.items do |s|
s.integer
s.string
end
x.optional_items do |y|
y.string
y.integer
end
end
tuple_schema.validate?([1,"hello"])
#=> true
tuple_schema.validate?([1,"hello", 3])
#=> false
tuple_schema.errors
#=> { "2" => "wrong type"}
tuple_schema.validate?([1,"hello", "world"])
#=> true
tuple_schema.validate?([1,"hello", "world", 3, 4])
#=> false
tuple_schema.errors
#=> { "base" => "expected at most 4 item(s) but got 5 item(s)"}
If you don't care whether the object is nil
or not, you can set :allow_nil => true
in the define
method:
nil_schema = DuckHunt::Schemas::Array.define, :allow_nil => false do |x|
x.integer
end
nil_schema.validate?([1,2,3])
#=> true
nil_schema.validate?(nil)
#=> true
Properties are the datatypes you can validate against in your schemas. They cover the basic datatypes you'd see when converying JSON to a ruby object:
- Array
- Boolean
- Float
- Integer
- Nested Hash
- Nil
- String
Sometimes you need nested objects, like nested hashes or multi-dimensional arrays. It's really easy to define these in Duck Hunt:
nested_hash = DuckHunt::Schemas::HashSchema.define do |x|
x.nested_hash "name" do |s|
s.string "first_name"
s.string "last_name"
end
end
nested_hash.validate?({:name => {:first_name => "Jane", :last_name => "Doe"}})
#=> true
nested_hash.validate?({:name => "hello"})
#=> false
nested_hash.errors
#=> {"name"=>{"base"=>["wrong type"]}}
nested_hash.validate?({:name => {:first_name => "Jane", :last_name => 1}})
#=> false
nested_hash.errors
#=> {"name"=>{"last_name"=>["wrong type"]}}
multi_array = DuckHunt::Schemas::ArraySchema.define do |x|
x.array do |y|
y.integer
end
end
multi_array.validate?([[1,2],[3,4]])
#=> true
multi_array.validate?([[1,2],"hello"])
#=> false
multi_array.errors
#=> {"1"=>{"base"=>["wrong type"]}}
multi_array.validate?([[1,2],["hello",4]])
#=> false
multi_array.errors
#=> {"1"=>{"0"=>["wrong type"]}}
Validators can be attached to any property to check if the value follows certain behavior. Each validator has its own error message
This property can only have the values in this list
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.integer "cats" :accepted_values => [1,2,3]
end
schema.validates?({:cats => 4})
#=> false
schema.errors
#=> {"0" => "not an accepted value"}
This property cannot have any of the values in this list
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.integer "cats" :rejected_values => [1,2,3]
end
schema.validates?({:cats => 1})
#=> false
schema.errors
#=> {"0" => "a rejected value"}
This property is only valid if it matches the regular expression given
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.string "name" :matches => /\w+\s\w+/
end
schema.validates?({ :name => "Bob" })
#=> false
schema.errors
#=> {"0" => "No matches for Regexp"}
This property is only valid if it's divisble by the number provided
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.integer "cats" :divisible_by => 3
end
schema.validates?({:cats => 4})
#=> false
schema.errors
#=> {"0" => "not divisible by `3`"}
This property is only valid if it's not divisble by the number provided
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.integer "cats" :not_divisible_by => 3
end
schema.validates?({:cats => 6})
#=> false
schema.errors
#=> {"0" => "divisible by `3`"}
This property is only valid if it fits the comparison. The comparisons defined are:
:equal_to
:not_equal_to
:greater_than
:greater_than_or_equal_to
:less_than
:less_than_or_equal_to
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.integer "cats" :greater_than => 4
end
schema.validates?({:cats => 4})
#=> false
schema.errors
#=> {"0" => "not greater than `4`"}
schema = DuckHunt::Schemas::HashSchema.define do |x|
x.integer "name" :equal => "bob"
end
schema.validates?({:name => "Jim"})
#=> false
schema.errors
#=> {"0" => "not equal to `bob`"}