shape_of 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/shape_of.rb +344 -0
  3. 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: []