media_types 2.1.1 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/debian.yml +43 -43
  3. data/.github/workflows/publish-bookworm.yml +33 -0
  4. data/.github/workflows/publish-sid.yml +33 -0
  5. data/.github/workflows/ruby.yml +22 -23
  6. data/.gitignore +20 -10
  7. data/.rubocop.yml +29 -29
  8. data/CHANGELOG.md +175 -164
  9. data/Gemfile +6 -6
  10. data/Gemfile.lock +9 -9
  11. data/LICENSE +21 -21
  12. data/README.md +666 -664
  13. data/Rakefile +12 -12
  14. data/bin/console +15 -15
  15. data/bin/setup +8 -8
  16. data/lib/media_types/constructable.rb +161 -160
  17. data/lib/media_types/dsl/errors.rb +18 -18
  18. data/lib/media_types/dsl.rb +172 -172
  19. data/lib/media_types/errors.rb +25 -19
  20. data/lib/media_types/formatter.rb +56 -56
  21. data/lib/media_types/hash.rb +21 -21
  22. data/lib/media_types/object.rb +35 -35
  23. data/lib/media_types/scheme/allow_nil.rb +30 -30
  24. data/lib/media_types/scheme/any_of.rb +41 -41
  25. data/lib/media_types/scheme/attribute.rb +46 -46
  26. data/lib/media_types/scheme/enumeration_context.rb +18 -18
  27. data/lib/media_types/scheme/enumeration_of_type.rb +80 -80
  28. data/lib/media_types/scheme/errors.rb +87 -87
  29. data/lib/media_types/scheme/links.rb +54 -54
  30. data/lib/media_types/scheme/missing_validation.rb +41 -41
  31. data/lib/media_types/scheme/not_strict.rb +15 -15
  32. data/lib/media_types/scheme/output_empty_guard.rb +45 -45
  33. data/lib/media_types/scheme/output_iterator_with_predicate.rb +66 -66
  34. data/lib/media_types/scheme/output_type_guard.rb +39 -39
  35. data/lib/media_types/scheme/rules.rb +186 -173
  36. data/lib/media_types/scheme/rules_exhausted_guard.rb +73 -73
  37. data/lib/media_types/scheme/validation_options.rb +44 -43
  38. data/lib/media_types/scheme.rb +535 -513
  39. data/lib/media_types/testing/assertions.rb +20 -20
  40. data/lib/media_types/validations.rb +118 -105
  41. data/lib/media_types/version.rb +5 -5
  42. data/lib/media_types/views.rb +12 -12
  43. data/lib/media_types.rb +73 -73
  44. data/media_types.gemspec +33 -33
  45. metadata +14 -12
@@ -1,513 +1,535 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
-
5
- require 'media_types/scheme/validation_options'
6
- require 'media_types/scheme/enumeration_context'
7
- require 'media_types/scheme/errors'
8
- require 'media_types/scheme/rules'
9
-
10
- require 'media_types/scheme/allow_nil'
11
- require 'media_types/scheme/any_of'
12
- require 'media_types/scheme/attribute'
13
- require 'media_types/scheme/enumeration_of_type'
14
- require 'media_types/scheme/links'
15
- require 'media_types/scheme/missing_validation'
16
- require 'media_types/scheme/not_strict'
17
-
18
- require 'media_types/scheme/output_empty_guard'
19
- require 'media_types/scheme/output_type_guard'
20
- require 'media_types/scheme/rules_exhausted_guard'
21
-
22
- module MediaTypes
23
- class AssertionError < StandardError
24
- def initialize(errors)
25
- @fixture_errors = errors
26
- end
27
-
28
- def message
29
- fixture_errors.map(&:message).join(', ')
30
- end
31
-
32
- attr_reader :fixture_errors
33
- end
34
-
35
- class UnexpectedValidationResultError < StandardError
36
- def initialize(fixture_caller, error)
37
- self.fixture_caller = fixture_caller
38
- self.error = error
39
- end
40
-
41
- def message
42
- format(
43
- '%<caller_path>s:%<caller_line>s -> %<error>s',
44
- caller_path: fixture_caller.path,
45
- caller_line: fixture_caller.lineno,
46
- error: error.is_a?(MediaTypes::Scheme::ValidationError) ? "#{error.class}:#{error.message}" : error
47
- )
48
- end
49
-
50
- attr_accessor :fixture_caller, :error
51
- end
52
-
53
- class FixtureData
54
- def initialize(caller:, fixture:, expect_to_pass:)
55
- self.caller = caller
56
- self.fixture = fixture
57
- self.expect_to_pass = expect_to_pass
58
- end
59
-
60
- attr_accessor :caller, :fixture, :expect_to_pass
61
-
62
- alias expect_to_pass? expect_to_pass
63
- end
64
-
65
- ##
66
- # Media Type Schemes can validate content to a media type, by itself. Used by the `validations` dsl.
67
- #
68
- # @see MediaTypes::Dsl
69
- #
70
- # @example A scheme to test against
71
- #
72
- # class MyMedia
73
- # include MediaTypes::Dsl
74
- #
75
- # validations do
76
- # attribute :foo do
77
- # collection :bar, String
78
- # end
79
- # attribute :number, Numeric
80
- # end
81
- # end
82
- #
83
- # MyMedia.valid?({ foo: { bar: ['test'] }, number: 42 })
84
- # #=> true
85
- #
86
- class Scheme
87
-
88
- ##
89
- # Creates a new scheme
90
- #
91
- # @param [TrueClass, FalseClass] allow_empty if true allows to be empty, if false raises EmptyOutputError if empty
92
- # @param [NilClass, Class] expected_type forces the type to be this type, if given
93
- #
94
- # @see MissingValidation
95
- #
96
- def initialize(allow_empty: false, expected_type: ::Object, &block)
97
- self.rules = Rules.new(allow_empty: allow_empty, expected_type: expected_type)
98
- self.type_attributes = {}
99
- self.fixtures = []
100
- self.asserted_sane = false
101
-
102
- instance_exec(&block) if block_given?
103
- end
104
-
105
- attr_accessor :type_attributes, :fixtures
106
- attr_reader :rules, :asserted_sane
107
-
108
- alias asserted_sane? asserted_sane
109
-
110
- ##
111
- # Checks if the +output+ is valid
112
- #
113
- # @param [#each] output the output to test against
114
- # @param [Hash] opts the options as defined below
115
- # @option exhaustive [TrueClass, FalseClass] opts if true, raises when it expected more data but there wasn't any
116
- # @option strict [TrueClass, FalseClass] opts if true, raised when it did not expect more data, but there was more
117
- #
118
- # @return [TrueClass, FalseClass] true if valid, false otherwise
119
- #
120
- def valid?(output, **opts)
121
- validate(output, **opts)
122
- rescue ExhaustedOutputError
123
- !opts.fetch(:exhaustive) { true }
124
- rescue ValidationError
125
- false
126
- end
127
-
128
- ##
129
- # Validates the +output+ and raises on certain validation errors
130
- #
131
- # @param [#each] output output to validate
132
- # @option opts [TrueClass, FalseClass] exhaustive if true, the entire schema needs to be consumed
133
- # @option opts [TrueClass, FalseClass] strict if true, no extra keys may be present in +output+
134
- # @option opts[Array<String>] backtrace the current backtrace for error messages
135
- #
136
- # @raise ExhaustedOutputError
137
- # @raise StrictValidationError
138
- # @raise EmptyOutputError
139
- # @raise CollectionTypeError
140
- # @raise ValidationError
141
- #
142
- # @see #validate!
143
- #
144
- # @return [TrueClass]
145
- #
146
- def validate(output, options = nil, **opts)
147
- options ||= ValidationOptions.new(**opts)
148
- options.context = output
149
-
150
- catch(:end) do
151
- validate!(output, options, context: nil)
152
- end
153
- end
154
-
155
- #
156
- # @private
157
- #
158
- def validate!(output, call_options, **_opts)
159
- OutputTypeGuard.call(output, call_options, rules: rules)
160
- OutputEmptyGuard.call(output, call_options, rules: rules)
161
- RulesExhaustedGuard.call(output, call_options, rules: rules)
162
- end
163
-
164
- ##
165
- # Adds an attribute to the schema
166
- # If a +block+ is given, uses that to test against instead of +type+
167
- #
168
- # @param key [Symbol] the attribute name
169
- # @param opts [Hash] options to pass to Scheme or Attribute
170
- # @param type [Class, #===, Scheme] The type of the value, can be anything that responds to #===,
171
- # or scheme to use if no +&block+ is given. Defaults to Object without a +&block+ and to Hash with a +&block+.
172
- # or scheme to use if no +&block+ is given. Defaults to Object without a +&block+ and to Hash with a +&block+.
173
- #
174
- # @see Scheme::Attribute
175
- # @see Scheme
176
- #
177
- # @example Add an attribute named foo, expecting a string
178
- #
179
- # class MyMedia
180
- # include MediaTypes::Dsl
181
- #
182
- # validations do
183
- # attribute :foo, String
184
- # end
185
- # end
186
- #
187
- # MyMedia.valid?({ foo: 'my-string' })
188
- # # => true
189
- #
190
- # @example Add an attribute named foo, expecting nested scheme
191
- #
192
- # class MyMedia
193
- # include MediaTypes::Dsl
194
- #
195
- # validations do
196
- # attribute :foo do
197
- # attribute :bar, String
198
- # end
199
- # end
200
- # end
201
- #
202
- # MyMedia.valid?({ foo: { bar: 'my-string' }})
203
- # # => true
204
- #
205
- def attribute(key, type = nil, optional: false, **opts, &block)
206
- raise ConflictingTypeDefinitionError, 'You cannot apply a block to a non-hash typed attribute, either remove the type or the block' if type != ::Hash && block_given? && !type.nil?
207
-
208
- type ||= ::Object
209
-
210
- if block_given?
211
- return collection(key, expected_type: ::Hash, optional: optional, **opts, &block)
212
- end
213
-
214
- if type.is_a?(Scheme)
215
- return rules.add(key, type, optional: optional)
216
- end
217
-
218
- rules.add(key, Attribute.new(type, **opts, &block), optional: optional)
219
- end
220
-
221
- ##
222
- # Allow for any key.
223
- # The +&block+ defines the Schema for each value.
224
- #
225
- # @param [Scheme, NilClass] scheme scheme to use if no +&block+ is given
226
- # @param [TrueClass, FalseClass] allow_empty if true, empty (no key/value present) is allowed
227
- # @param [Class] expected_type forces the validated object to have this type
228
- #
229
- # @see Scheme
230
- #
231
- # @example Add a collection named foo, expecting any key with a defined value
232
- #
233
- # class MyMedia
234
- # include MediaTypes::Dsl
235
- #
236
- # validations do
237
- # collection :foo do
238
- # any do
239
- # attribute :bar, String
240
- # end
241
- # end
242
- # end
243
- # end
244
- #
245
- # MyMedia.valid?({ foo: [{ anything: { bar: 'my-string' }, other_thing: { bar: 'other-string' } }] })
246
- # # => true
247
- #
248
- # @example Any key, but all of them String or Numeric
249
- #
250
- # class MyMedia
251
- # include MediaTypes::Dsl
252
- #
253
- # validations do
254
- # any AnyOf(String, Numeric)
255
- # end
256
- # end
257
- #
258
- # MyMedia.valid?({ foo: 'my-string', bar: 42 })
259
- # # => true
260
- #
261
- def any(scheme = nil, expected_type: ::Hash, allow_empty: false, &block)
262
- raise ConflictingTypeDefinitionError, 'You cannot apply a block to a non-hash typed property, either remove the type or the block' if scheme != ::Hash && block_given? && !scheme.nil?
263
-
264
- unless block_given?
265
- if scheme.is_a?(Scheme)
266
- return rules.default = scheme
267
- end
268
-
269
- return rules.default = Attribute.new(scheme)
270
- end
271
-
272
- rules.default = Scheme.new(allow_empty: allow_empty, expected_type: expected_type, &block)
273
- end
274
-
275
- ##
276
- # Merges a +scheme+ into this scheme without changing the incoming +scheme+
277
- #
278
- # @param [Scheme] scheme the scheme to merge into this
279
- #
280
- def merge(scheme, &block)
281
- self.rules = rules.merge(scheme.send(:rules))
282
- instance_exec(&block) if block_given?
283
- end
284
-
285
- ##
286
- # Allow for extra keys in the schema/collection even when passing strict: true to #validate!
287
- #
288
- # @see Scheme::NotStrict
289
- #
290
- # @example Allow for extra keys in collection
291
- #
292
- # class MyMedia
293
- # include MediaTypes::Dsl
294
- #
295
- # validations do
296
- # collection :foo do
297
- # attribute :required, String
298
- # not_strict
299
- # end
300
- # end
301
- # end
302
- #
303
- # MyMedia.valid?({ foo: [{ required: 'test', bar: 42 }] })
304
- # # => true
305
- #
306
- def not_strict
307
- rules.default = NotStrict.new
308
- end
309
-
310
- ##
311
- # Expect a collection such as an array or hash.
312
- # The +block+ defines the Schema for each item in that collection.
313
- #
314
- # @param [Symbol] key key of the collection (same as #attribute)
315
- # @param [NilClass, Scheme, Class] scheme scheme to use if no +&block+ is given, or type of each item in collection
316
- # @param [TrueClass, FalseClass] allow_empty if true accepts 0 items in an enumerable
317
- # @param [Class] expected_type forces the value of this collection to be this type, defaults to Array.
318
- #
319
- # @see Scheme
320
- #
321
- # @example Collection with an array of string
322
- #
323
- # class MyMedia
324
- # include MediaTypes::Dsl
325
- #
326
- # validations do
327
- # collection :foo, String
328
- # end
329
- # end
330
- #
331
- # MyMedia.valid?({ collection: ['foo', 'bar'] })
332
- # # => true
333
- #
334
- # @example Collection with defined scheme
335
- #
336
- # class MyMedia
337
- # include MediaTypes::Dsl
338
- #
339
- # validations do
340
- # collection :foo do
341
- # attribute :required, String
342
- # attribute :number, Numeric
343
- # end
344
- # end
345
- # end
346
- #
347
- # MyMedia.valid?({ foo: [{ required: 'test', number: 42 }, { required: 'other', number: 0 }] })
348
- # # => true
349
- #
350
- def collection(key, scheme = nil, allow_empty: false, expected_type: ::Array, optional: false, &block)
351
- raise ConflictingTypeDefinitionError, 'You cannot apply a block to a non-hash typed collection, either remove the type or the block' if scheme != ::Hash && block_given? && !scheme.nil?
352
-
353
- unless block_given?
354
- return rules.add(
355
- key,
356
- EnumerationOfType.new(
357
- scheme,
358
- enumeration_type: expected_type,
359
- allow_empty: allow_empty
360
- ),
361
- optional: optional
362
- )
363
- end
364
-
365
- rules.add(key, Scheme.new(allow_empty: allow_empty, expected_type: expected_type, &block), optional: optional)
366
- end
367
-
368
- ##
369
- # Expect a link
370
- #
371
- # @see Scheme::Links
372
- #
373
- # @example Links as defined in HAL, JSON-Links and other specs
374
- #
375
- # class MyMedia
376
- # include MediaTypes::Dsl
377
- #
378
- # validations do
379
- # link :_self
380
- # link :image
381
- # end
382
- # end
383
- #
384
- # MyMedia.valid?({ _links: { self: { href: 'https://example.org/s' }, image: { href: 'https://image.org/i' }} })
385
- # # => true
386
- #
387
- # @example Link with extra attributes
388
- #
389
- # class MyMedia
390
- # include MediaTypes::Dsl
391
- #
392
- # validations do
393
- # link :image do
394
- # attribute :templated, TrueClass
395
- # end
396
- # end
397
- # end
398
- #
399
- # MyMedia.valid?({ _links: { image: { href: 'https://image.org/{md5}', templated: true }} })
400
- # # => true
401
- #
402
- def link(*args, **opts, &block)
403
- rules.fetch(:_links) do
404
- Links.new.tap do |links|
405
- rules.add(:_links, links)
406
- end
407
- end.link(*args, **opts, &block)
408
- end
409
-
410
- ##
411
- # Mark object as a valid empty object
412
- #
413
- # @example Empty object
414
- #
415
- # class MyMedia
416
- # include MediaTypes::Dsl
417
- #
418
- # validations do
419
- # empty
420
- # end
421
- # end
422
- def empty
423
- end
424
-
425
- def inspect(indentation = 0)
426
- tabs = ' ' * indentation
427
- [
428
- "#{tabs}[Scheme]",
429
- rules.inspect(indentation + 1),
430
- "#{tabs}[/Scheme]"
431
- ].join("\n")
432
- end
433
-
434
- def assert_pass(fixture)
435
- reduced_stack = remove_current_dir_from_stack(caller_locations)
436
- @fixtures << FixtureData.new(caller: reduced_stack.first, fixture: fixture, expect_to_pass: true)
437
- end
438
-
439
- def assert_fail(fixture)
440
- reduced_stack = remove_current_dir_from_stack(caller_locations)
441
- @fixtures << FixtureData.new(caller: reduced_stack.first, fixture: fixture, expect_to_pass: false)
442
- end
443
-
444
- # Removes all calls originating in current dir from given stack
445
- # We need this so that we find out the caller of an assert_pass/fail in the caller_locations
446
- # Which gets polluted by Scheme consecutively executing blocks within the validation blocks
447
- def remove_current_dir_from_stack(stack)
448
- stack.reject { |location| location.path.include?(__dir__) }
449
- end
450
-
451
- def validate_scheme_fixtures(expect_symbol_keys, backtrace)
452
- @fixtures.map do |fixture_data|
453
- begin
454
- validate_fixture(fixture_data, expect_symbol_keys, backtrace)
455
- nil
456
- rescue UnexpectedValidationResultError => e
457
- e
458
- end
459
- end.compact
460
- end
461
-
462
- def validate_nested_scheme_fixtures(expect_symbol_keys, backtrace)
463
- @rules.flat_map do |key, rule|
464
- next unless rule.is_a?(Scheme) || rule.is_a?(Links)
465
-
466
- begin
467
- rule.run_fixture_validations(expect_symbol_keys, backtrace.dup.append(key))
468
- nil
469
- rescue AssertionError => e
470
- e.fixture_errors
471
- end
472
- end.compact
473
- end
474
-
475
- def validate_default_scheme_fixtures(expect_symbol_keys, backtrace)
476
- return [] unless @rules.default.is_a?(Scheme)
477
-
478
- @rules.default.run_fixture_validations(expect_symbol_keys, backtrace.dup.append('*'))
479
- []
480
- rescue AssertionError => e
481
- e.fixture_errors
482
- end
483
-
484
- def run_fixture_validations(expect_symbol_keys, backtrace = [])
485
- fixture_errors = validate_scheme_fixtures(expect_symbol_keys, backtrace)
486
- fixture_errors += validate_nested_scheme_fixtures(expect_symbol_keys, backtrace)
487
- fixture_errors += validate_default_scheme_fixtures(expect_symbol_keys, backtrace)
488
-
489
- raise AssertionError, fixture_errors unless fixture_errors.empty?
490
-
491
- self.asserted_sane = true
492
- end
493
-
494
- def validate_fixture(fixture_data, expect_symbol_keys, backtrace = [])
495
- json = JSON.parse(fixture_data.fixture, { symbolize_names: expect_symbol_keys })
496
- expected_key_type = expect_symbol_keys ? Symbol : String
497
-
498
- begin
499
- validate(json, expected_key_type: expected_key_type, backtrace: backtrace)
500
- unless fixture_data.expect_to_pass?
501
- raise UnexpectedValidationResultError.new(fixture_data.caller, 'No error encounterd whilst expecting to')
502
- end
503
- rescue MediaTypes::Scheme::ValidationError => e
504
- raise UnexpectedValidationResultError.new(fixture_data.caller, e) if fixture_data.expect_to_pass?
505
- end
506
- end
507
-
508
- private
509
-
510
- attr_writer :rules, :asserted_sane
511
-
512
- end
513
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'media_types/scheme/validation_options'
6
+ require 'media_types/scheme/enumeration_context'
7
+ require 'media_types/scheme/errors'
8
+ require 'media_types/scheme/rules'
9
+
10
+ require 'media_types/scheme/allow_nil'
11
+ require 'media_types/scheme/any_of'
12
+ require 'media_types/scheme/attribute'
13
+ require 'media_types/scheme/enumeration_of_type'
14
+ require 'media_types/scheme/links'
15
+ require 'media_types/scheme/missing_validation'
16
+ require 'media_types/scheme/not_strict'
17
+
18
+ require 'media_types/scheme/output_empty_guard'
19
+ require 'media_types/scheme/output_type_guard'
20
+ require 'media_types/scheme/rules_exhausted_guard'
21
+
22
+ module MediaTypes
23
+ class AssertionError < StandardError
24
+ def initialize(errors)
25
+ @fixture_errors = errors
26
+ end
27
+
28
+ def message
29
+ fixture_errors.map(&:message).join(', ')
30
+ end
31
+
32
+ attr_reader :fixture_errors
33
+ end
34
+
35
+ class UnexpectedValidationResultError < StandardError
36
+ def initialize(fixture_caller, error)
37
+ self.fixture_caller = fixture_caller
38
+ self.error = error
39
+ end
40
+
41
+ def message
42
+ format(
43
+ '%<caller_path>s:%<caller_line>s -> %<error>s',
44
+ caller_path: fixture_caller.path,
45
+ caller_line: fixture_caller.lineno,
46
+ error: error.is_a?(MediaTypes::Scheme::ValidationError) ? "#{error.class}:#{error.message}" : error
47
+ )
48
+ end
49
+
50
+ attr_accessor :fixture_caller, :error
51
+ end
52
+
53
+ class FixtureData
54
+ def initialize(caller:, fixture:, expect_to_pass:, loose:)
55
+ self.caller = caller
56
+ self.fixture = fixture
57
+ self.expect_to_pass = expect_to_pass
58
+ self.loose = loose
59
+ end
60
+
61
+ attr_accessor :caller, :fixture, :expect_to_pass, :loose
62
+
63
+ alias expect_to_pass? expect_to_pass
64
+ end
65
+
66
+ ##
67
+ # Media Type Schemes can validate content to a media type, by itself. Used by the `validations` dsl.
68
+ #
69
+ # @see MediaTypes::Dsl
70
+ #
71
+ # @example A scheme to test against
72
+ #
73
+ # class MyMedia
74
+ # include MediaTypes::Dsl
75
+ #
76
+ # validations do
77
+ # attribute :foo do
78
+ # collection :bar, String
79
+ # end
80
+ # attribute :number, Numeric
81
+ # end
82
+ # end
83
+ #
84
+ # MyMedia.valid?({ foo: { bar: ['test'] }, number: 42 })
85
+ # #=> true
86
+ #
87
+ class Scheme
88
+
89
+ ##
90
+ # Creates a new scheme
91
+ #
92
+ # @param [TrueClass, FalseClass] allow_empty if true allows to be empty, if false raises EmptyOutputError if empty
93
+ # @param [NilClass, Class] expected_type forces the type to be this type, if given
94
+ #
95
+ # @see MissingValidation
96
+ #
97
+ def initialize(allow_empty: false, expected_type: ::Object, current_type: nil, registry: nil, &block)
98
+ self.rules = Rules.new(allow_empty: allow_empty, expected_type: expected_type)
99
+ self.type_attributes = {}
100
+ self.fixtures = []
101
+ self.asserted_sane = false
102
+ @registry = registry
103
+ @current_type = current_type
104
+
105
+ instance_exec(&block) if block_given?
106
+ end
107
+
108
+ attr_accessor :type_attributes, :fixtures
109
+ attr_reader :rules, :asserted_sane
110
+
111
+ alias asserted_sane? asserted_sane
112
+
113
+ ##
114
+ # Checks if the +output+ is valid
115
+ #
116
+ # @param [#each] output the output to test against
117
+ # @param [Hash] opts the options as defined below
118
+ # @option exhaustive [TrueClass, FalseClass] opts if true, raises when it expected more data but there wasn't any
119
+ # @option strict [TrueClass, FalseClass] opts if true, raised when it did not expect more data, but there was more
120
+ #
121
+ # @return [TrueClass, FalseClass] true if valid, false otherwise
122
+ #
123
+ def valid?(output, **opts)
124
+ validate(output, **opts)
125
+ rescue ExhaustedOutputError
126
+ !opts.fetch(:exhaustive) { true }
127
+ rescue ValidationError
128
+ false
129
+ end
130
+
131
+ ##
132
+ # Validates the +output+ and raises on certain validation errors
133
+ #
134
+ # @param [#each] output output to validate
135
+ # @option opts [TrueClass, FalseClass] exhaustive if true, the entire schema needs to be consumed
136
+ # @option opts [TrueClass, FalseClass] strict if true, no extra keys may be present in +output+
137
+ # @option opts[Array<String>] backtrace the current backtrace for error messages
138
+ #
139
+ # @raise ExhaustedOutputError
140
+ # @raise StrictValidationError
141
+ # @raise EmptyOutputError
142
+ # @raise CollectionTypeError
143
+ # @raise ValidationError
144
+ #
145
+ # @see #validate!
146
+ #
147
+ # @return [TrueClass]
148
+ #
149
+ def validate(output, options = nil, **opts)
150
+ options ||= ValidationOptions.new(**opts)
151
+ options.context = output
152
+
153
+ catch(:end) do
154
+ validate!(output, options, context: nil)
155
+ end
156
+ end
157
+
158
+ #
159
+ # @private
160
+ #
161
+ def validate!(output, call_options, **_opts)
162
+ OutputTypeGuard.call(output, call_options, rules: rules)
163
+ OutputEmptyGuard.call(output, call_options, rules: rules)
164
+ RulesExhaustedGuard.call(output, call_options, rules: rules)
165
+ end
166
+
167
+ ##
168
+ # Adds an attribute to the schema
169
+ # If a +block+ is given, uses that to test against instead of +type+
170
+ #
171
+ # @param key [Symbol] the attribute name
172
+ # @param opts [Hash] options to pass to Scheme or Attribute
173
+ # @param type [Class, #===, Scheme] The type of the value, can be anything that responds to #===,
174
+ # or scheme to use if no +&block+ is given. Defaults to Object without a +&block+ and to Hash with a +&block+.
175
+ # or scheme to use if no +&block+ is given. Defaults to Object without a +&block+ and to Hash with a +&block+.
176
+ #
177
+ # @see Scheme::Attribute
178
+ # @see Scheme
179
+ #
180
+ # @example Add an attribute named foo, expecting a string
181
+ #
182
+ # class MyMedia
183
+ # include MediaTypes::Dsl
184
+ #
185
+ # validations do
186
+ # attribute :foo, String
187
+ # end
188
+ # end
189
+ #
190
+ # MyMedia.valid?({ foo: 'my-string' })
191
+ # # => true
192
+ #
193
+ # @example Add an attribute named foo, expecting nested scheme
194
+ #
195
+ # class MyMedia
196
+ # include MediaTypes::Dsl
197
+ #
198
+ # validations do
199
+ # attribute :foo do
200
+ # attribute :bar, String
201
+ # end
202
+ # end
203
+ # end
204
+ #
205
+ # MyMedia.valid?({ foo: { bar: 'my-string' }})
206
+ # # => true
207
+ #
208
+ def attribute(key, type = nil, optional: false, **opts, &block)
209
+ raise ConflictingTypeDefinitionError, 'You cannot apply a block to a non-hash typed attribute, either remove the type or the block' if type != ::Hash && block_given? && !type.nil?
210
+
211
+ type ||= ::Object
212
+
213
+ if block_given?
214
+ return collection(key, expected_type: ::Hash, optional: optional, **opts, &block)
215
+ end
216
+
217
+ if type.is_a?(Scheme)
218
+ return rules.add(key, type, optional: optional)
219
+ end
220
+
221
+ rules.add(key, Attribute.new(type, **opts, &block), optional: optional)
222
+ end
223
+
224
+ ##
225
+ # Allow for any key.
226
+ # The +&block+ defines the Schema for each value.
227
+ #
228
+ # @param [Scheme, NilClass] scheme scheme to use if no +&block+ is given
229
+ # @param [TrueClass, FalseClass] allow_empty if true, empty (no key/value present) is allowed
230
+ # @param [Class] expected_type forces the validated object to have this type
231
+ #
232
+ # @see Scheme
233
+ #
234
+ # @example Add a collection named foo, expecting any key with a defined value
235
+ #
236
+ # class MyMedia
237
+ # include MediaTypes::Dsl
238
+ #
239
+ # validations do
240
+ # collection :foo do
241
+ # any do
242
+ # attribute :bar, String
243
+ # end
244
+ # end
245
+ # end
246
+ # end
247
+ #
248
+ # MyMedia.valid?({ foo: [{ anything: { bar: 'my-string' }, other_thing: { bar: 'other-string' } }] })
249
+ # # => true
250
+ #
251
+ # @example Any key, but all of them String or Numeric
252
+ #
253
+ # class MyMedia
254
+ # include MediaTypes::Dsl
255
+ #
256
+ # validations do
257
+ # any AnyOf(String, Numeric)
258
+ # end
259
+ # end
260
+ #
261
+ # MyMedia.valid?({ foo: 'my-string', bar: 42 })
262
+ # # => true
263
+ #
264
+ def any(scheme = nil, expected_type: ::Hash, allow_empty: false, &block)
265
+ raise ConflictingTypeDefinitionError, 'You cannot apply a block to a non-hash typed property, either remove the type or the block' if scheme != ::Hash && block_given? && !scheme.nil?
266
+
267
+ unless block_given?
268
+ if scheme.is_a?(Scheme)
269
+ return rules.default = scheme
270
+ end
271
+
272
+ return rules.default = Attribute.new(scheme)
273
+ end
274
+
275
+ rules.default = Scheme.new(allow_empty: allow_empty, expected_type: expected_type, registry: @registry, current_type: @current_type, &block)
276
+ end
277
+
278
+ ##
279
+ # Merges a +scheme+ into this scheme without changing the incoming +scheme+
280
+ #
281
+ # @param [Scheme] scheme the scheme to merge into this
282
+ #
283
+ def merge(scheme, &block)
284
+ self.rules = rules.merge(scheme.send(:rules))
285
+ instance_exec(&block) if block_given?
286
+ end
287
+
288
+ ##
289
+ # Allow for extra keys in the schema/collection even when passing strict: true to #validate!
290
+ #
291
+ # @see Scheme::NotStrict
292
+ #
293
+ # @example Allow for extra keys in collection
294
+ #
295
+ # class MyMedia
296
+ # include MediaTypes::Dsl
297
+ #
298
+ # validations do
299
+ # collection :foo do
300
+ # attribute :required, String
301
+ # not_strict
302
+ # end
303
+ # end
304
+ # end
305
+ #
306
+ # MyMedia.valid?({ foo: [{ required: 'test', bar: 42 }] })
307
+ # # => true
308
+ #
309
+ def not_strict
310
+ rules.default = NotStrict.new
311
+ end
312
+
313
+ ##
314
+ # Expect a collection such as an array or hash.
315
+ # The +block+ defines the Schema for each item in that collection.
316
+ #
317
+ # @param [Symbol] key key of the collection (same as #attribute)
318
+ # @param [NilClass, Scheme, Class] scheme scheme to use if no +&block+ is given, or type of each item in collection
319
+ # @param [TrueClass, FalseClass] allow_empty if true accepts 0 items in an enumerable
320
+ # @param [Class] expected_type forces the value of this collection to be this type, defaults to Array.
321
+ #
322
+ # @see Scheme
323
+ #
324
+ # @example Collection with an array of string
325
+ #
326
+ # class MyMedia
327
+ # include MediaTypes::Dsl
328
+ #
329
+ # validations do
330
+ # collection :foo, String
331
+ # end
332
+ # end
333
+ #
334
+ # MyMedia.valid?({ collection: ['foo', 'bar'] })
335
+ # # => true
336
+ #
337
+ # @example Collection with defined scheme
338
+ #
339
+ # class MyMedia
340
+ # include MediaTypes::Dsl
341
+ #
342
+ # validations do
343
+ # collection :foo do
344
+ # attribute :required, String
345
+ # attribute :number, Numeric
346
+ # end
347
+ # end
348
+ # end
349
+ #
350
+ # MyMedia.valid?({ foo: [{ required: 'test', number: 42 }, { required: 'other', number: 0 }] })
351
+ # # => true
352
+ #
353
+ def collection(key, scheme = nil, view: nil, allow_empty: false, expected_type: ::Array, optional: false, &block)
354
+ raise ConflictingTypeDefinitionError, 'You cannot apply a block to a non-hash typed collection, either remove the type or the block' if scheme != ::Hash && block_given? && !scheme.nil?
355
+
356
+ unless block_given?
357
+ if scheme.nil?
358
+ dependent_key = @current_type.as_key.dup
359
+ dependent_key[1] = view
360
+
361
+ unless @registry.has_key? dependent_key
362
+ raise Errors::CollectionDefinitionNotFound.new(@current_type.override_suffix('json').to_s, @current_type.view(view).override_suffix('json').to_s)
363
+ end
364
+ scheme = @registry[dependent_key]
365
+ end
366
+
367
+ return rules.add(
368
+ key,
369
+ EnumerationOfType.new(
370
+ scheme,
371
+ enumeration_type: expected_type,
372
+ allow_empty: allow_empty
373
+ ),
374
+ optional: optional
375
+ )
376
+ end
377
+
378
+ rules.add(key, Scheme.new(allow_empty: allow_empty, expected_type: expected_type, registry: @registry, current_type: @current_type, &block), optional: optional)
379
+ end
380
+
381
+ ##
382
+ # Expect an index of links
383
+ #
384
+ def index(optional: false)
385
+ collection(:_links, optional: optional) do
386
+ link :_self
387
+ end
388
+ end
389
+
390
+ ##
391
+ # Expect a link
392
+ #
393
+ # @see Scheme::Links
394
+ #
395
+ # @example Links as defined in HAL, JSON-Links and other specs
396
+ #
397
+ # class MyMedia
398
+ # include MediaTypes::Dsl
399
+ #
400
+ # validations do
401
+ # link :_self
402
+ # link :image
403
+ # end
404
+ # end
405
+ #
406
+ # MyMedia.valid?({ _links: { self: { href: 'https://example.org/s' }, image: { href: 'https://image.org/i' }} })
407
+ # # => true
408
+ #
409
+ # @example Link with extra attributes
410
+ #
411
+ # class MyMedia
412
+ # include MediaTypes::Dsl
413
+ #
414
+ # validations do
415
+ # link :image do
416
+ # attribute :templated, TrueClass
417
+ # end
418
+ # end
419
+ # end
420
+ #
421
+ # MyMedia.valid?({ _links: { image: { href: 'https://image.org/{md5}', templated: true }} })
422
+ # # => true
423
+ #
424
+ def link(*args, **opts, &block)
425
+ rules.fetch(:_links) do
426
+ Links.new.tap do |links|
427
+ rules.add(:_links, links)
428
+ end
429
+ end.link(*args, **opts, &block)
430
+ end
431
+
432
+ ##
433
+ # Mark object as a valid empty object
434
+ #
435
+ # @example Empty object
436
+ #
437
+ # class MyMedia
438
+ # include MediaTypes::Dsl
439
+ #
440
+ # validations do
441
+ # empty
442
+ # end
443
+ # end
444
+ def empty
445
+ end
446
+
447
+ def inspect(indentation = 0)
448
+ tabs = ' ' * indentation
449
+ [
450
+ "#{tabs}[Scheme]",
451
+ rules.inspect(indentation + 1),
452
+ "#{tabs}[/Scheme]"
453
+ ].join("\n")
454
+ end
455
+
456
+ def assert_pass(fixture, loose: false)
457
+ reduced_stack = remove_current_dir_from_stack(caller_locations)
458
+ @fixtures << FixtureData.new(caller: reduced_stack.first, fixture: fixture, expect_to_pass: true, loose: loose)
459
+ end
460
+
461
+ def assert_fail(fixture, loose: false)
462
+ reduced_stack = remove_current_dir_from_stack(caller_locations)
463
+ @fixtures << FixtureData.new(caller: reduced_stack.first, fixture: fixture, expect_to_pass: false, loose: loose)
464
+ end
465
+
466
+ # Removes all calls originating in current dir from given stack
467
+ # We need this so that we find out the caller of an assert_pass/fail in the caller_locations
468
+ # Which gets polluted by Scheme consecutively executing blocks within the validation blocks
469
+ def remove_current_dir_from_stack(stack)
470
+ stack.reject { |location| location.path.include?(__dir__) }
471
+ end
472
+
473
+ def validate_scheme_fixtures(expect_symbol_keys, backtrace)
474
+ @fixtures.map do |fixture_data|
475
+ begin
476
+ validate_fixture(fixture_data, expect_symbol_keys, backtrace)
477
+ nil
478
+ rescue UnexpectedValidationResultError => e
479
+ e
480
+ end
481
+ end.compact
482
+ end
483
+
484
+ def validate_nested_scheme_fixtures(expect_symbol_keys, backtrace)
485
+ @rules.flat_map do |key, rule|
486
+ next unless rule.is_a?(Scheme) || rule.is_a?(Links)
487
+
488
+ begin
489
+ rule.run_fixture_validations(expect_symbol_keys, backtrace.dup.append(key))
490
+ nil
491
+ rescue AssertionError => e
492
+ e.fixture_errors
493
+ end
494
+ end.compact
495
+ end
496
+
497
+ def validate_default_scheme_fixtures(expect_symbol_keys, backtrace)
498
+ return [] unless @rules.default.is_a?(Scheme)
499
+
500
+ @rules.default.run_fixture_validations(expect_symbol_keys, backtrace.dup.append('*'))
501
+ []
502
+ rescue AssertionError => e
503
+ e.fixture_errors
504
+ end
505
+
506
+ def run_fixture_validations(expect_symbol_keys, backtrace = [])
507
+ fixture_errors = validate_scheme_fixtures(expect_symbol_keys, backtrace)
508
+ fixture_errors += validate_nested_scheme_fixtures(expect_symbol_keys, backtrace)
509
+ fixture_errors += validate_default_scheme_fixtures(expect_symbol_keys, backtrace)
510
+
511
+ raise AssertionError, fixture_errors unless fixture_errors.empty?
512
+
513
+ self.asserted_sane = true
514
+ end
515
+
516
+ def validate_fixture(fixture_data, expect_symbol_keys, backtrace = [])
517
+ json = JSON.parse(fixture_data.fixture, { symbolize_names: expect_symbol_keys })
518
+ expected_key_type = expect_symbol_keys ? Symbol : String
519
+
520
+ begin
521
+ validate(json, expected_key_type: expected_key_type, backtrace: backtrace, loose: fixture_data.loose)
522
+ unless fixture_data.expect_to_pass?
523
+ raise UnexpectedValidationResultError.new(fixture_data.caller, 'No error encounterd whilst expecting to')
524
+ end
525
+ rescue MediaTypes::Scheme::ValidationError => e
526
+ raise UnexpectedValidationResultError.new(fixture_data.caller, e) if fixture_data.expect_to_pass?
527
+ end
528
+ end
529
+
530
+ private
531
+
532
+ attr_writer :rules, :asserted_sane
533
+
534
+ end
535
+ end