ShapeOf
A RubyGem that provides a way to verify "shapes" of objects.
This is licenced under the MIT license, Copyright 2022 John Isom.
Example Usage
Taken from lib/shape_of.rb
.
For example, given this hash, where friendly_name
, external_id
, external_avatar_url
, and data
are optional:
hash = {
id: 123,
name: "John Doe",
friendly_name: "Johnny",
external_id: "",
external_avatar_url: "https://example.com/avatar.jpg",
data: {
status: "VIP"
},
identities: [
{
id: 1,
type: "email",
identifier: "john37@example.com"
}
],
created_at: "2020-12-28T15:55:35.121Z",
updated_at: "2020-12-28T15:55:35.121Z"
}
the proper shape would be this:
shape = ShapeOf::Hash[
id: Integer,
name: String,
friendly_name: ShapeOf::Optional[String],
external_id: ShapeOf::Optional[String],
external_avatar_url: ShapeOf::Optional[String],
data: ShapeOf::Optional[Hash],
identities: ShapeOf::Array[
ShapeOf::Hash[
id: Integer,
type: String,
identifier: String
]
],
created_at: String,
updated_at: String
]
shape.shape_of? hash # => true
As another example, given this shape:
hash_shape = ShapeOf::Hash[
value: ShapeOf::Optional[
ShapeOf::Union[
ShapeOf::Array[
ShapeOf::Hash[
inner_value: ShapeOf::Any
]
],
ShapeOf::Hash[
inner_value: ShapeOf::Any
]
]
]
]
These shapes pass:
hash_shape.shape_of?({ value: { inner_value: 3 } }) # => true
hash_shape.shape_of?({ value: [{ inner_value: 3 }] }) # => true
hash_shape.shape_of?({ value: [{ inner_value: 3 }, { inner_value: "foo" }, { inner_value: [1, 2, 3] }] }) # => true
And these fail:
hash_shape.shape_of?({ foo: { inner_value: 'bar' } }) # => false
hash_shape.shape_of?({ value: 23 }) # => false
hash_shape.shape_of?({ value: [23] }) # => false
hash_shape.shape_of?({ value: [{}] }) # => false
Other Usage
Alternatively, ShapeOf can be used with ruby objects rather than ShapeOf::Hash
and ShapeOf::Array
:
ShapeOf::Hash[
id: Integer,
identities: [
{
id: Integer,
data: {
type: String
}
}
]
]
# that is equivalent to:
ShapeOf::Hash[
id: Integer,
identities: ShapeOf::Array[
ShapeOf::Hash[
id: Integer,
data: ShapeOf::Hash[
type: String
]
]
]
]
# which in turn is equivalent to
{
id: Integer,
identities: [
{
id: Integer,
data: {
type: String
}
}
]
}.to_shape_of
ShapeOf can also be used to test actual values instead of classes. For example:
shape = ShapeOf::Union["hello", "world", "foobar", 1, 1.423]
shape.shape_of? "hello" # => true
shape.shape_of? "world" # => true
shape.shape_of? "foobar" # => true
shape.shape_of? 1 # => true
shape.shape_of? 1.423 # => true
shape.shape_of? "other string" # => false
shape.shape_of? nil # => false
shape.shape_of? 1.42300001 # => false
shape.shape_of? Object.new # => false
So, if you wanted to test that the field foo
in a hash is optional but if it exists
it either is a String or the integers 1-5, you could do so like this:
shape = ShapeOf::Hash[bar: String, foo: ShapeOf::Optional[ShapeOf::Union[String, *1..5]]]
shape.shape_of?({ bar: 'foobar' }) # => true
shape.shape_of?({}) # => false
shape.shape_of?({ bar: '', foo: 1 }) # => true
shape.shape_of?({ bar: '', foo: 2 }) # => true
shape.shape_of?({ bar: '', foo: 3 }) # => true
shape.shape_of?({ bar: '', foo: 4 }) # => true
shape.shape_of?({ bar: '', foo: 5 }) # => true
shape.shape_of?({ bar: '', foo: 6 }) # => false
shape.shape_of?({ bar: '', foo: "6" }) # => true
shape.shape_of?({ bar: '', foo: nil }) # => true
The Validator
New in v3.0.0, ShapeOf comes with a validator to make testing the shape of objects even easier. See this example usage:
a_shape = ShapeOf::Hash[foo: "bar", baz: Integer]
an_object = { foo: "bar", baz: 525 }
validator = ShapeOf::Validator.new(shape: a_shape, object: an_object)
validator.valid? # => true
validator.errors # => nil
And for a shape that doesn't match up:
a_shape = ShapeOf::Hash[
non_existent: ShapeOf::Nothing,
is_existent: String,
list: ShapeOf::Array[Integer],
union: ShapeOf::Union[0, 1, ShapeOf::Boolean, "true", "false", Class]
]
an_object = {
non_existent: "should have error here",
list: [1, "12", "234", 2, 5, nil],
union: "yes"
}
validator = ShapeOf::Validator.new(shape: a_shape, object: an_object)
validator.valid? # => false
validator.errors # => {"is_existent"=>{:errors=>["required key not present"]}, "non_existent"=>{:errors=>["key present when not allowed"]}, "list"=>{:idx_1=>{:errors=>["\"12\" is not instance of Integer"]}, :idx_2=>{:errors=>["\"234\" is not instance of Integer"]}, :idx_5=>{:errors=>["nil is not instance of Integer"]}}, "union"=>{:errors=>["\"yes\" is not shape of any of (ShapeOf::Boolean) or is not instance of any of (Class) or is not equal to (==) any of (0, 1, \"true\", \"false\")"]}}
Provided Shapes
ShapeOf::Hash
Pulled from comments from the source code:
Hash[key: shape, ...]
denotes it is a hash of shapes with a very specific structure.Hash
(without square brackets) is just a hash with any shape. This, along withArray
, are the core components of this module. Note that the keys are converted to strings for comparison for both the shape and object provided.
Example:
# note that keyword args can be provided directly or wrapped in curly braces
shape = ShapeOf::Hash[foo: String, bar: ShapeOf::Hash[{ baz: Integer }]]
shape.shape_of?({ foo: "", bar: { baz: 1 } }) # => true
shape.shape_of?({ foo: "foo", bar: { baz: -2 } }) # => true
shape.shape_of?({}) # => false
shape.shape_of?({ bar: { baz: 2 } }) # => false
shape.shape_of?({ foo: "foo", bar: {} }) # => false
shape.shape_of?({ foo: "foo", bar: { baz: -2, blamo: nil } }) # => false
shape.shape_of?({ foo: "foo", bar: { baz: -2 }, blamo: nil }) # => false
ShapeOf::Array
Pulled from comments from the source code:
Array[shape]
denotes that it is an array of shapes. It checks every element in the array and verifies that the element is in the correct shape. This, along withHash
, are the core components of this module. Note that aShapeOf::Array[Integer].shape_of?([])
will pass because it is vacuously true for an empty array.
Example:
shape = ShapeOf::Array[String]
shape.shape_of?([]) # => true
shape.shape_of?(["foobar"]) # => true
shape.shape_of?(["a", "b", "c", "d"]) # => true
shape.shape_of?(["a", "b", 1, "d"]) # => false
shape.shape_of?(["a", "b", nil, "d"]) # => false
ShapeOf::Union
Pulled from comments from the source code:
Union[shape1, shape2, ...]
denotes that it can be of one the provided shapes.
Example:
shape = ShapeOf::Union[String, ShapeOf::Array[String]]
shape.shape_of?("") # => true
shape.shape_of?("foo") # => true
shape.shape_of?([]) # => true
shape.shape_of?(["foo", "bar", "baz"]) # => true
shape.shape_of?(["foo", 1]) # => false
shape.shape_of?(nil) # => false
ShapeOf::Optional
Pulled from comments from the source code:
Optional[shape]
denotes that the usual type is ashape
, but is optional. (meaning if it isnil
or the key is not present in theHash
, it's still true).
Example:
shape = ShapeOf::Optional[ShapeOf::Boolean]
shape.shape_of?(nil) # => true
shape.shape_of?(true) # => true
shape.shape_of?(false) # => true
shape.shape_of?(1) # => false
shape.shape_of?("") # => false
Example with ShapeOf::Hash
:
shape = ShapeOf::Hash[foo: String, bar: ShapeOf::Optional[ShapeOf::Boolean]]
shape.shape_of?({ foo: "", bar: nil }) # => true
shape.shape_of?({ foo: "", bar: true }) # => true
shape.shape_of?({ foo: "", bar: false }) # => true
shape.shape_of?({ foo: "" }) # => true
shape.shape_of?({ foo: "", bar: 1 }) # => false
shape.shape_of?({ foo: "", bar: "" }) # => false
ShapeOf::Pattern
The ShapeOf::Pattern[/regexp pattern/]
is used to match a Regexp
against a String
using Regexp#match?
.
shape = ShapeOf::Pattern[/foobar$/i]
shape.shape_of?("foobar") # => true
shape.shape_of?("fOobAr\n") # => true
shape.shape_of?("\n\nfoobar\n") # => true
shape.shape_of?("foo\nbarfoo\nfoobar\nfo\nobar\n") # => true
shape.shape_of?("There once was a barfoo who foobared. Foobar") # => true
shape.shape_of?("foo\nbar\n") # => false
ShapeOf::Boolean
ShapeOf::Union[TrueClass, FalseClass]
ShapeOf::Numeric
ShapeOf::Union[Integer, Float, Rational, Complex]
ShapeOf::Any
Anything matches unless key does not exist in the ShapeOf::Hash
.
ShapeOf::Nothing
Only passes when the key does not exist in the ShapeOf::Hash
.
With MiniTest
Included is a ShapeOf::Assertions
module which includes 2 methods: assert_shape_of
, and refute_shape_of
.
Keep in mind that the order of the "expected" and "actual" value for these assertions are backwards of
what Minitest assertions are.
In ShapeOf, by default, the actual comes first, then the expected shape comes after. However, with the
release of v3.0.0, the correct order can be used, by calling ShapeOf::Assertions.use_proper_expected_actual_order!
before using any of the assertions in your tests.
# test.rb
require 'shape_of'
ShapeOf::Assertions.use_proper_expected_actual_order!
require 'minitest/autorun'
class MyTestClass < MiniTest::Test
include ShapeOf::Assertions
def test_a_shape
to_test = [{ foo: 1, bar: nil }, { foo: 34 }]
assert_shape_of(ShapeOf::Array[
ShapeOf::Hash[
foo: Integer,
bar: ShapeOf::Optional[Integer],
]
], to_test) # assertion passes
assert_shape_of(ShapeOf::Array[
ShapeOf::Hash[
foo: "whoa",
bar: "nil",
baz: nil
]
], to_test) # assertion fails with this message:
=begin
FAIL MyTestClass#test_a_shape (0.15s)
{:idx_0=>
{"baz"=>{:errors=>["required key not present"]},
"foo"=>{:errors=>["1 is not equal to (==) \"whoa\""]},
"bar"=>{:errors=>["nil is not equal to (==) \"nil\""]}},
:idx_1=>
{"bar"=>{:errors=>["required key not present"]},
"baz"=>{:errors=>["required key not present"]},
"foo"=>{:errors=>["34 is not equal to (==) \"whoa\""]}}}
=end
end
end