shape_of 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/shape_of.rb +344 -0
- metadata +43 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0c42b5e14e8ba448eac77c5fc6f3ad8c71dd20577fcdfd9bb12611d5d9c61aa2
|
|
4
|
+
data.tar.gz: c8d658a759422c4780102bdf15845f90c9abb2ec02397e37872d8733b7243500
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f788452f8b9d53c5c76fafb9bb9af3da10bf2fce527dd95a6d6ac26b9c0b6bad27d4df584e832e2f76f839d7518f34e599b0b9710358f90b55bd95f022ae816f
|
|
7
|
+
data.tar.gz: 63da2ef6a2b5d7490701e7e7f610319a594f73bb3a9fc918bfed1b71f0ca6bdf44770a74ab6afe1de740708e096a28367639e54c6a3ed9cdee59a9fbfe39adc8
|
data/lib/shape_of.rb
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# Copyright 2021 John Isom.
|
|
2
|
+
# Licensed under the MIT open source license.
|
|
3
|
+
|
|
4
|
+
# The ShapeOf module can be used in testing JSON APIs to make sure that
|
|
5
|
+
# the body of the result is of the correct format. It is similar to a type
|
|
6
|
+
# checker, but a bit more generic.
|
|
7
|
+
#
|
|
8
|
+
# For example, given this hash, where `friendly_name`, `external_id`, `external_avatar_url`, and `data` are optional:
|
|
9
|
+
# ```ruby
|
|
10
|
+
# hash = {
|
|
11
|
+
# id: 123,
|
|
12
|
+
# name: "John Doe",
|
|
13
|
+
# friendly_name: "Johnny",
|
|
14
|
+
# external_id: "",
|
|
15
|
+
# external_avatar_url: "https://example.com/avatar.jpg",
|
|
16
|
+
# data: {
|
|
17
|
+
# status: "VIP"
|
|
18
|
+
# },
|
|
19
|
+
# identities: [
|
|
20
|
+
# {
|
|
21
|
+
# id: 1,
|
|
22
|
+
# type: "email",
|
|
23
|
+
# identifier: "john37@example.com"
|
|
24
|
+
# }
|
|
25
|
+
# ],
|
|
26
|
+
# created_at: "2020-12-28T15:55:35.121Z",
|
|
27
|
+
# updated_at: "2020-12-28T15:55:35.121Z"
|
|
28
|
+
# }
|
|
29
|
+
# ```
|
|
30
|
+
|
|
31
|
+
# the proper shape would be this:
|
|
32
|
+
# ```ruby
|
|
33
|
+
# shape = ShapeOf::Hash[
|
|
34
|
+
# id: Integer,
|
|
35
|
+
# name: String,
|
|
36
|
+
# friendly_name: ShapeOf::Optional[String],
|
|
37
|
+
# external_id: ShapeOf::Optional[String],
|
|
38
|
+
# external_avatar_url: ShapeOf::Optional[String],
|
|
39
|
+
# data: ShapeOf::Optional[Hash],
|
|
40
|
+
# identities: ShapeOf::Array[
|
|
41
|
+
# ShapeOf::Hash[
|
|
42
|
+
# id: Integer,
|
|
43
|
+
# type: String,
|
|
44
|
+
# identifier: String
|
|
45
|
+
# ]
|
|
46
|
+
# ],
|
|
47
|
+
# created_at: String,
|
|
48
|
+
# updated_at: String
|
|
49
|
+
# ]
|
|
50
|
+
|
|
51
|
+
# shape.shape_of? hash # => true
|
|
52
|
+
# ```
|
|
53
|
+
|
|
54
|
+
# As another example, given this shape:
|
|
55
|
+
# ```ruby
|
|
56
|
+
# hash_shape = ShapeOf::Hash[
|
|
57
|
+
# value: ShapeOf::Optional[
|
|
58
|
+
# ShapeOf::Union[
|
|
59
|
+
# ShapeOf::Array[
|
|
60
|
+
# ShapeOf::Hash[
|
|
61
|
+
# inner_value: ShapeOf::Any
|
|
62
|
+
# ]
|
|
63
|
+
# ],
|
|
64
|
+
# ShapeOf::Hash[
|
|
65
|
+
# inner_value: ShapeOf::Any
|
|
66
|
+
# ]
|
|
67
|
+
# ]
|
|
68
|
+
# ]
|
|
69
|
+
# ]
|
|
70
|
+
# ```
|
|
71
|
+
|
|
72
|
+
# These shapes pass:
|
|
73
|
+
# ```ruby
|
|
74
|
+
# hash_shape.shape_of?({ value: { inner_value: 3 } }) # => true
|
|
75
|
+
# hash_shape.shape_of?({ value: [{ inner_value: 3 }] }) # => true
|
|
76
|
+
# hash_shape.shape_of?({ value: [{ inner_value: 3 }, { inner_value: "foo" }, { inner_value: [1, 2, 3] }] }) # => true
|
|
77
|
+
# ```
|
|
78
|
+
|
|
79
|
+
# And these fail:
|
|
80
|
+
# ```ruby
|
|
81
|
+
# hash_shape.shape_of?({ foo: { inner_value: 'bar' } }) # => false
|
|
82
|
+
# hash_shape.shape_of?({ value: 23 }) # => false
|
|
83
|
+
# hash_shape.shape_of?({ value: [23] }) # => false
|
|
84
|
+
# hash_shape.shape_of?({ value: [{}] }) # => false
|
|
85
|
+
# ```
|
|
86
|
+
#
|
|
87
|
+
module ShapeOf
|
|
88
|
+
# To be included in a MiniTest test class
|
|
89
|
+
module Assertions
|
|
90
|
+
def assert_shape_of(object, shape)
|
|
91
|
+
if shape.respond_to? :shape_of?
|
|
92
|
+
assert shape.shape_of? object
|
|
93
|
+
elsif shape.instance_of? ::Array
|
|
94
|
+
assert Array[shape.first].shape_of? object
|
|
95
|
+
elsif shape.instance_of? ::Hash
|
|
96
|
+
assert Hash[shape].shape_of? object
|
|
97
|
+
else
|
|
98
|
+
raise TypeError, "Expected #{Shape.inspect}, an #{::Array.inspect}, or a #{::Hash.inspect} as the shape"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def refute_shape_of(object, shape)
|
|
103
|
+
if shape.respond_to? :shape_of?
|
|
104
|
+
refute shape.shape_of? object
|
|
105
|
+
elsif shape.instance_of? ::Array
|
|
106
|
+
refute Array[shape.first].shape_of? object
|
|
107
|
+
elsif shape.instance_of? ::Hash
|
|
108
|
+
refute Hash[shape].shape_of? object
|
|
109
|
+
else
|
|
110
|
+
raise TypeError, "Expected #{Shape.inspect}, an #{::Array.inspect}, or a #{::Hash.inspect} as the shape"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Generic shape which all shapes subclass from
|
|
116
|
+
class Shape
|
|
117
|
+
def self.shape_of?(*)
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.required?
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def initialize(*)
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Array[Shape] denotes that it is an array of shapes.
|
|
131
|
+
# It checks every element in the array and verifies that the element is in the correct shape.
|
|
132
|
+
# This, along with Array, are the core components of this module.
|
|
133
|
+
# Note that a ShapeOf::Array[Integer].shape_of?([]) will pass because it is vacuously true for an empty array.
|
|
134
|
+
class Array < Shape
|
|
135
|
+
@internal_class = ::Array
|
|
136
|
+
|
|
137
|
+
def self.shape_of?(object)
|
|
138
|
+
object.instance_of? @internal_class
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.[](shape)
|
|
142
|
+
Class.new(self) do
|
|
143
|
+
@class_name = "#{superclass.name}[#{shape.inspect}]"
|
|
144
|
+
@shape = shape
|
|
145
|
+
@internal_class = superclass.instance_variable_get(:@internal_class)
|
|
146
|
+
|
|
147
|
+
def self.name
|
|
148
|
+
@class_name
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.to_s
|
|
152
|
+
@class_name
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.inspect
|
|
156
|
+
@class_name
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.shape_of?(array)
|
|
160
|
+
super && array.all? do |elem|
|
|
161
|
+
if @shape.respond_to? :shape_of?
|
|
162
|
+
@shape.shape_of? elem
|
|
163
|
+
elsif @shape.is_a? ::Array
|
|
164
|
+
Array[@shape].shape_of? elem
|
|
165
|
+
elsif @shape.is_a? ::Hash
|
|
166
|
+
Hash[@shape].shape_of? elem
|
|
167
|
+
elsif @shape.is_a? Class
|
|
168
|
+
elem.instance_of? @shape
|
|
169
|
+
else
|
|
170
|
+
elem == @shape
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 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.
|
|
179
|
+
# This, along with Array, are the core components of this module.
|
|
180
|
+
# Note that the keys are converted to strings for comparison for both the shape and object provided.
|
|
181
|
+
class Hash < Shape
|
|
182
|
+
@internal_class = ::Hash
|
|
183
|
+
|
|
184
|
+
def self.shape_of?(object)
|
|
185
|
+
object.instance_of? @internal_class
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.[](shape = {})
|
|
189
|
+
raise TypeError, "Shape must be Hash, was #{shape.class.name}" unless shape.instance_of? ::Hash
|
|
190
|
+
|
|
191
|
+
Class.new(self) do
|
|
192
|
+
@class_name = "#{superclass.name}[#{shape.map { |(k, v)| "#{k.to_s}: #{v.inspect}" }.join(', ')}]"
|
|
193
|
+
@shape = stringify_rb_hash_keys(shape)
|
|
194
|
+
@internal_class = superclass.instance_variable_get(:@internal_class)
|
|
195
|
+
|
|
196
|
+
def self.name
|
|
197
|
+
@class_name
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.to_s
|
|
201
|
+
@class_name
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.inspect
|
|
205
|
+
@class_name
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.shape_of?(hash)
|
|
209
|
+
return false unless super
|
|
210
|
+
|
|
211
|
+
rb_hash = stringify_rb_hash_keys(hash)
|
|
212
|
+
|
|
213
|
+
rb_hash.keys.each do |key|
|
|
214
|
+
return false unless @shape.key?(key)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
@shape.each do |key, shape|
|
|
218
|
+
return false unless rb_hash.key?(key) || shape.respond_to?(:required?) && !shape.required?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
rb_hash.all? do |key, elem|
|
|
222
|
+
if @shape[key].respond_to? :shape_of?
|
|
223
|
+
@shape[key].shape_of? elem
|
|
224
|
+
elsif @shape[key].is_a? ::Array
|
|
225
|
+
Array[@shape[key]].shape_of? elem
|
|
226
|
+
elsif @shape[key].is_a? ::Hash
|
|
227
|
+
Hash[@shape[key]].shape_of? elem
|
|
228
|
+
elsif @shape[key].is_a? Class
|
|
229
|
+
elem.instance_of? @shape[key]
|
|
230
|
+
else
|
|
231
|
+
elem == @shape[key]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def self.stringify_rb_hash_keys(rb_hash)
|
|
241
|
+
rb_hash.to_a.map { |k, v| [k.to_s, v] }.to_h
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Union[Shape1, Shape2, ...] denotes that it can be of one the provided shapes
|
|
246
|
+
class Union < Shape
|
|
247
|
+
def self.shape_of?(object)
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def self.[](*shapes)
|
|
252
|
+
Class.new(self) do
|
|
253
|
+
@class_name = "#{superclass.name}[#{shapes.map(&:inspect).join(", ")}]"
|
|
254
|
+
@shapes = shapes
|
|
255
|
+
|
|
256
|
+
def self.name
|
|
257
|
+
@class_name
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.to_s
|
|
261
|
+
@class_name
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.inspect
|
|
265
|
+
@class_name
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def self.shape_of?(object)
|
|
269
|
+
@shapes.any? do |shape|
|
|
270
|
+
if shape.respond_to? :shape_of?
|
|
271
|
+
shape.shape_of? object
|
|
272
|
+
elsif shape.is_a? ::Hash
|
|
273
|
+
Hash[shape].shape_of? object
|
|
274
|
+
elsif shape.is_a? ::Array
|
|
275
|
+
Array[shape].shape_of? object
|
|
276
|
+
elsif shape.is_a? Class
|
|
277
|
+
object.instance_of? shape
|
|
278
|
+
else
|
|
279
|
+
object == shape
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Optional[Shape] denotes that the usual type is a Shape, but is optional (meaning if it is nil or the key is not present in the Hash, it's still true)
|
|
288
|
+
class Optional < Shape
|
|
289
|
+
def self.[](shape)
|
|
290
|
+
raise TypeError, "Shape cannot be nil" if shape.nil? || shape == NilClass
|
|
291
|
+
|
|
292
|
+
Union[shape, NilClass].tap do |this|
|
|
293
|
+
new_class_name = this.name.sub('Union', 'Optional').sub(/(?<=\[).*(?=\])/, shape.inspect)
|
|
294
|
+
this.instance_variable_set(:@class_name, new_class_name)
|
|
295
|
+
def this.required?
|
|
296
|
+
false
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def self.required?
|
|
302
|
+
false
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
class Any < Shape
|
|
307
|
+
def self.shape_of?(object)
|
|
308
|
+
true
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Nothing only passes when the key does not exist in the Hash.
|
|
313
|
+
class Nothing < Shape
|
|
314
|
+
def self.shape_of?(object)
|
|
315
|
+
false
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def self.required?
|
|
319
|
+
false
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
Numeric = Union[Integer, Float, Rational, Complex].tap do |this|
|
|
324
|
+
this.instance_variable_set(:@class_name, this.name.sub(/Union.*/, 'Numeric'))
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
Boolean = Union[TrueClass, FalseClass].tap do |this|
|
|
328
|
+
this.instance_variable_set(:@class_name, this.name.sub(/Union.*/, 'Boolean'))
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Monkey patch
|
|
333
|
+
class Hash
|
|
334
|
+
def to_shape_of
|
|
335
|
+
ShapeOf::Hash[self]
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Monkey patch
|
|
340
|
+
class Array
|
|
341
|
+
def to_shape_of
|
|
342
|
+
ShapeOf::Array[self.first]
|
|
343
|
+
end
|
|
344
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shape_of
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- John Isom
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2021-05-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email: john@johnisom.dev
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- lib/shape_of.rb
|
|
20
|
+
homepage:
|
|
21
|
+
licenses:
|
|
22
|
+
- MIT
|
|
23
|
+
metadata: {}
|
|
24
|
+
post_install_message:
|
|
25
|
+
rdoc_options: []
|
|
26
|
+
require_paths:
|
|
27
|
+
- lib
|
|
28
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
34
|
+
requirements:
|
|
35
|
+
- - ">="
|
|
36
|
+
- !ruby/object:Gem::Version
|
|
37
|
+
version: '0'
|
|
38
|
+
requirements: []
|
|
39
|
+
rubygems_version: 3.2.15
|
|
40
|
+
signing_key:
|
|
41
|
+
specification_version: 4
|
|
42
|
+
summary: A shape/type checker for Ruby objects.
|
|
43
|
+
test_files: []
|