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.
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: []