media_types 2.2.0 → 2.3.0

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 (44) 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 -22
  6. data/.gitignore +20 -10
  7. data/.rubocop.yml +29 -29
  8. data/CHANGELOG.md +175 -164
  9. data/Gemfile +6 -6
  10. data/LICENSE +21 -21
  11. data/README.md +666 -664
  12. data/Rakefile +12 -12
  13. data/bin/console +15 -15
  14. data/bin/setup +8 -8
  15. data/lib/media_types/constructable.rb +161 -160
  16. data/lib/media_types/dsl/errors.rb +18 -18
  17. data/lib/media_types/dsl.rb +172 -172
  18. data/lib/media_types/errors.rb +25 -19
  19. data/lib/media_types/formatter.rb +56 -56
  20. data/lib/media_types/hash.rb +21 -21
  21. data/lib/media_types/object.rb +35 -35
  22. data/lib/media_types/scheme/allow_nil.rb +30 -30
  23. data/lib/media_types/scheme/any_of.rb +41 -41
  24. data/lib/media_types/scheme/attribute.rb +46 -46
  25. data/lib/media_types/scheme/enumeration_context.rb +18 -18
  26. data/lib/media_types/scheme/enumeration_of_type.rb +80 -80
  27. data/lib/media_types/scheme/errors.rb +87 -87
  28. data/lib/media_types/scheme/links.rb +54 -54
  29. data/lib/media_types/scheme/missing_validation.rb +41 -41
  30. data/lib/media_types/scheme/not_strict.rb +15 -15
  31. data/lib/media_types/scheme/output_empty_guard.rb +45 -45
  32. data/lib/media_types/scheme/output_iterator_with_predicate.rb +66 -66
  33. data/lib/media_types/scheme/output_type_guard.rb +39 -39
  34. data/lib/media_types/scheme/rules.rb +186 -173
  35. data/lib/media_types/scheme/rules_exhausted_guard.rb +73 -73
  36. data/lib/media_types/scheme/validation_options.rb +44 -43
  37. data/lib/media_types/scheme.rb +535 -513
  38. data/lib/media_types/testing/assertions.rb +20 -20
  39. data/lib/media_types/validations.rb +118 -105
  40. data/lib/media_types/version.rb +5 -5
  41. data/lib/media_types/views.rb +12 -12
  42. data/lib/media_types.rb +73 -73
  43. data/media_types.gemspec +33 -33
  44. metadata +8 -6
@@ -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