shape_of 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|