media_types 0.1.3 → 0.2.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.
@@ -1,31 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'media_types/scheme/validation_options'
4
+ require 'media_types/scheme/enumeration_context'
5
+
3
6
  require 'media_types/scheme/allow_nil'
4
7
  require 'media_types/scheme/attribute'
8
+ require 'media_types/scheme/enumeration_of_type'
5
9
  require 'media_types/scheme/links'
6
10
  require 'media_types/scheme/missing_validation'
7
11
  require 'media_types/scheme/not_strict'
8
12
 
9
13
  module MediaTypes
10
- class ValidationError < ArgumentError
11
- end
14
+ ##
15
+ # Media Type Schemes can validate content to a media type, by itself. Used by the `validations` dsl.
16
+ #
17
+ # @see MediaTypes::Dsl
18
+ #
19
+ # @example A scheme to test against
20
+ #
21
+ # class MyMedia
22
+ # include MediaTypes::Dsl
23
+ #
24
+ # validations do
25
+ # attribute :foo do
26
+ # collection :bar, String
27
+ # end
28
+ # attribute :number, Numeric
29
+ # end
30
+ # end
31
+ #
32
+ # MyMedia.valid?({ foo: { bar: ['test'] }, number: 42 })
33
+ # #=> true
34
+ #
35
+ class Scheme
12
36
 
13
- class ExhaustedOutputError < ValidationError
14
- end
37
+ # Base class for all validations errors
38
+ class ValidationError < ArgumentError; end
15
39
 
16
- class StrictValidationError < ValidationError
17
- end
40
+ # Raised when it expected more data but there wasn't any left
41
+ class ExhaustedOutputError < ValidationError; end
18
42
 
19
- class EmptyOutputError < ValidationError
20
- end
43
+ # Raised when it did not expect more data, but there was more left
44
+ class StrictValidationError < ValidationError; end
21
45
 
22
- ##
23
- # Media Type Schemes can validate content to a media type, by itself.
24
- #
25
- class Scheme
26
- def initialize(allow_empty: false)
46
+ # Raised when it expected not to be empty, but it was
47
+ class EmptyOutputError < ValidationError; end
48
+
49
+ # Raised when a value did not have the expected collection type
50
+ class CollectionTypeError < ValidationError; end
51
+
52
+ ##
53
+ # Creates a new scheme
54
+ #
55
+ # @param [TrueClass, FalseClass] allow_empty if true allows to be empty, if false raises EmptyOutputError if empty
56
+ # @param [NilClass, Class] force forces the type to be this type, if given
57
+ #
58
+ # @see MissingValidation
59
+ #
60
+ def initialize(allow_empty: false, force: nil)
27
61
  self.validations = {}
28
62
  self.allow_empty = allow_empty
63
+ self.force = force
29
64
 
30
65
  validations.default = MissingValidation.new
31
66
  end
@@ -33,12 +68,12 @@ module MediaTypes
33
68
  ##
34
69
  # Checks if the +output+ is valid
35
70
  #
36
- # @param [#each] output
37
- # @param [Hash] opts
38
- # @option exhaustive [Boolean] opts
39
- # @option strict [Boolean] opts
71
+ # @param [#each] output the output to test against
72
+ # @param [Hash] opts the options as defined below
73
+ # @option exhaustive [TrueClass, FalseClass] opts if true, raises when it expected more data but there wasn't any
74
+ # @option strict [TrueClass, FalseClass] opts if true, raised when it did not expect more data, but there was more
40
75
  #
41
- # @return [Boolean] true if valid, false otherwise
76
+ # @return [TrueClass, FalseClass] true if valid, false otherwise
42
77
  #
43
78
  def valid?(output, **opts)
44
79
  validate(output, **opts)
@@ -48,41 +83,22 @@ module MediaTypes
48
83
  false
49
84
  end
50
85
 
51
- class ValidationOptions
52
- attr_accessor :exhaustive, :strict, :backtrace
53
-
54
- def initialize(exhaustive: true, strict: true, backtrace: [])
55
- self.exhaustive = exhaustive
56
- self.strict = strict
57
- self.backtrace = backtrace
58
- end
59
-
60
- def with_backtrace(backtrace)
61
- ValidationOptions.new(exhaustive: exhaustive, strict: strict, backtrace: backtrace)
62
- end
63
-
64
- def trace(*traces)
65
- with_backtrace(backtrace.dup.concat(traces))
66
- end
67
-
68
- def exhaustive!
69
- ValidationOptions.new(exhaustive: true, strict: strict, backtrace: backtrace)
70
- end
71
- end
72
-
73
86
  ##
74
87
  # Validates the +output+ and raises on certain validation errors
75
88
  #
76
89
  # @param [#each] output output to validate
77
- # @option opts [Boolean] exhaustive if true, the entire schema needs to be consumed
78
- # @option opts [Boolean] strict if true, no extra keys may be present in +output+
90
+ # @option opts [TrueClass, FalseClass] exhaustive if true, the entire schema needs to be consumed
91
+ # @option opts [TrueClass, FalseClass] strict if true, no extra keys may be present in +output+
79
92
  # @option opts[Array<String>] backtrace the current backtrace for error messages
80
93
  #
81
94
  # @raise ExhaustedOutputError
82
95
  # @raise StrictValidationError
83
96
  # @raise EmptyOutputError
97
+ # @raise CollectionTypeError
84
98
  # @raise ValidationError
85
99
  #
100
+ # @see #validate!
101
+ #
86
102
  # @return [TrueClass]
87
103
  #
88
104
  def validate(output, options = nil, **opts)
@@ -93,6 +109,9 @@ module MediaTypes
93
109
  end
94
110
  end
95
111
 
112
+ #
113
+ # @private
114
+ #
96
115
  def validate!(output, call_options, **_opts)
97
116
  empty_guard!(output, call_options)
98
117
 
@@ -111,39 +130,114 @@ module MediaTypes
111
130
 
112
131
  ##
113
132
  # Adds an attribute to the schema
133
+ # If a +block+ is given, uses that to test against instead of +type+
114
134
  #
115
135
  # @param key [Symbol] the attribute name
116
- # @param type [Class, #===] The type of the value, can be anything that responds to #===
117
- # @param opts [Hash] options
136
+ # @param opts [Hash] options to pass to Scheme or Attribute
137
+ # @param type [Class, #===, Scheme] The type of the value, can be anything that responds to #===,
138
+ # or scheme to use if no +&block+ is given. Defaults to String without a +&block+ and to Hash with a +&block+.
139
+ #
140
+ # @see Scheme::Attribute
141
+ # @see Scheme
118
142
  #
119
143
  # @example Add an attribute named foo, expecting a string
120
144
  #
121
- # class MyMedia < Base
122
- # current_schema do
145
+ # class MyMedia
146
+ # include MediaTypes::Dsl
147
+ #
148
+ # validations do
123
149
  # attribute :foo, String
124
150
  # end
125
151
  # end
126
152
  #
153
+ # MyMedia.valid?({ foo: 'my-string' })
154
+ # # => true
155
+ #
156
+ # @example Add an attribute named foo, expecting nested scheme
157
+ #
158
+ # class MyMedia
159
+ # include MediaTypes::Dsl
160
+ #
161
+ # validations do
162
+ # attribute :foo do
163
+ # attribute :bar, String
164
+ # end
165
+ # end
166
+ # end
167
+ #
168
+ # MyMedia.valid?({ foo: { bar: 'my-string' }})
169
+ # # => true
170
+ #
127
171
  def attribute(key, type = String, **opts, &block)
172
+ if block_given?
173
+ return collection(key, force: ::Hash, **opts, &block)
174
+ end
175
+
176
+ if type.is_a?(Scheme)
177
+ return validations[key] = type
178
+ end
179
+
128
180
  validations[key] = Attribute.new(type, **opts, &block)
129
181
  end
130
182
 
131
183
  ##
132
184
  # Allow for any key.
133
- # The +block+ defines the Schema for each value.
185
+ # The +&block+ defines the Schema for each value.
186
+ #
187
+ # @param [Scheme, NilClass] scheme scheme to use if no +&block+ is given
188
+ # @param [TrueClass, FalseClass] allow_empty if true, empty (no key/value present) is allowed
189
+ # @param [Class] force forces the validated object to have this type
190
+ #
191
+ # @see Scheme
134
192
  #
135
- # @param [Boolean] allow_empty if true, empty (no key/value present) is allowed
193
+ # @example Add a collection named foo, expecting any key with a defined value
136
194
  #
137
- def any(allow_empty: false, &block)
138
- scheme = Scheme.new(allow_empty: allow_empty)
195
+ # class MyMedia
196
+ # include MediaTypes::Dsl
197
+ #
198
+ # validations do
199
+ # collection :foo do
200
+ # any do
201
+ # attribute :bar, String
202
+ # end
203
+ # end
204
+ # end
205
+ # end
206
+ #
207
+ # MyMedia.valid?({ foo: [{ anything: { bar: 'my-string' }, other_thing: { bar: 'other-string' } }] })
208
+ # # => true
209
+ #
210
+ def any(scheme = nil, force: ::Hash, allow_empty: false, &block)
211
+ unless block_given?
212
+ return validations.default = scheme
213
+ end
214
+
215
+ scheme = Scheme.new(allow_empty: allow_empty, force: force)
139
216
  scheme.instance_exec(&block)
140
217
 
141
218
  validations.default = scheme
142
219
  end
143
220
 
144
221
  ##
145
- # Allow for extra keys in the schema/collection
146
- # even when passing strict: true to #validate!
222
+ # Allow for extra keys in the schema/collection even when passing strict: true to #validate!
223
+ #
224
+ # @see Scheme::NotStrict
225
+ #
226
+ # @example Allow for extra keys in collection
227
+ #
228
+ # class MyMedia
229
+ # include MediaTypes::Dsl
230
+ #
231
+ # validations do
232
+ # collection :foo do
233
+ # attribute :required, String
234
+ # not_strict
235
+ # end
236
+ # end
237
+ # end
238
+ #
239
+ # MyMedia.valid?({ foo: [{ required: 'test', bar: 42 }] })
240
+ # # => true
147
241
  #
148
242
  def not_strict
149
243
  validations.default = NotStrict.new
@@ -153,11 +247,56 @@ module MediaTypes
153
247
  # Expect a collection such as an array or hash.
154
248
  # The +block+ defines the Schema for each item in that collection.
155
249
  #
156
- # @param [Symbol] key
157
- # @param [Boolean] allow_empty, if true accepts 0 items in an array / hash
250
+ # @param [Symbol] key key of the collection (same as #attribute)
251
+ # @param [NilClass, Scheme, Class] scheme scheme to use if no +&block+ is given, or type of each item in collection
252
+ # @param [TrueClass, FalseClass] allow_empty if true accepts 0 items in an enumerable
253
+ # @param [Class] force forces the value of this collection to be this type, defaults to Array.
254
+ #
255
+ # @see Scheme
256
+ #
257
+ # @example Collection with an array of string
258
+ #
259
+ # class MyMedia
260
+ # include MediaTypes::Dsl
261
+ #
262
+ # validations do
263
+ # collection :foo, String
264
+ # end
265
+ # end
266
+ #
267
+ # MyMedia.valid?({ collection: ['foo', 'bar'] })
268
+ # # => true
269
+ #
270
+ # @example Collection with defined scheme
158
271
  #
159
- def collection(key, allow_empty: false, &block)
160
- scheme = Scheme.new(allow_empty: allow_empty)
272
+ # class MyMedia
273
+ # include MediaTypes::Dsl
274
+ #
275
+ # validations do
276
+ # collection :foo do
277
+ # attribute :required, String
278
+ # attribute :number, Numeric
279
+ # end
280
+ # end
281
+ # end
282
+ #
283
+ # MyMedia.valid?({ foo: [{ required: 'test', number: 42 }, { required: 'other', number: 0 }] })
284
+ # # => true
285
+ #
286
+ def collection(key, scheme = nil, allow_empty: false, force: Array, &block)
287
+ unless block_given?
288
+ if scheme.is_a?(Scheme)
289
+ return validations[key] = scheme
290
+ end
291
+
292
+ return validations[key] = EnumerationOfType.new(
293
+ scheme,
294
+ enumeration_type: force,
295
+ allow_empty: allow_empty
296
+ )
297
+ end
298
+
299
+ scheme = Scheme.new(allow_empty: allow_empty, force: force)
161
300
  scheme.instance_exec(&block)
162
301
 
163
302
  validations[key] = scheme
@@ -166,6 +305,37 @@ module MediaTypes
166
305
  ##
167
306
  # Expect a link
168
307
  #
308
+ # @see Scheme::Links
309
+ #
310
+ # @example Links as defined in HAL, JSON-Links and other specs
311
+ #
312
+ # class MyMedia
313
+ # include MediaTypes::Dsl
314
+ #
315
+ # validations do
316
+ # link :_self
317
+ # link :image
318
+ # end
319
+ # end
320
+ #
321
+ # MyMedia.valid?({ _links: { self: { href: 'https://example.org/s' }, image: { href: 'https://image.org/i' }} })
322
+ # # => true
323
+ #
324
+ # @example Link with extra attributes
325
+ #
326
+ # class MyMedia
327
+ # include MediaTypes::Dsl
328
+ #
329
+ # validations do
330
+ # link :image do
331
+ # attribute :templated, TrueClass
332
+ # end
333
+ # end
334
+ # end
335
+ #
336
+ # MyMedia.valid?({ _links: { image: { href: 'https://image.org/{md5}', templated: true }} })
337
+ # # => true
338
+ #
169
339
  def link(*args, **opts, &block)
170
340
  validations.fetch(:_links) do
171
341
  Links.new.tap do |links|
@@ -176,41 +346,51 @@ module MediaTypes
176
346
 
177
347
  private
178
348
 
179
- attr_accessor :validations, :allow_empty
349
+ attr_accessor :validations, :allow_empty, :force
180
350
 
351
+ ##
352
+ # Checks if the output is nil or empty
353
+ #
354
+ # @private
355
+ #
181
356
  def empty_guard!(output, options)
182
- return unless output.nil? || output.empty?
357
+ return unless MediaTypes::Object.new(output).empty?
183
358
  throw(:end, true) if allow_empty
184
359
  raise_empty!(backtrace: options.backtrace)
185
360
  end
186
361
 
187
- class EnumerationContext
188
- def initialize(validations:)
189
- self.validations = validations
190
- end
191
-
192
- def enumerate(val)
193
- self.key = val
194
- self
195
- end
196
-
197
- attr_accessor :validations, :key
198
- end
199
-
362
+ ##
363
+ # Mimics Enumerable#all? with mandatory +&block+
364
+ #
200
365
  def all?(enumerable, options, &block)
201
366
  context = EnumerationContext.new(validations: validations)
202
367
 
368
+ if force && !(force === enumerable) # rubocop:disable Style/CaseEquality
369
+ raise_forced_type_error!(type: enumerable.class, backtrace: options.backtrace)
370
+ end
371
+
203
372
  if enumerable.is_a?(Hash) || enumerable.respond_to?(:key)
204
373
  return enumerable.all? do |key, value|
205
374
  yield key, value, options: options, context: context.enumerate(key)
206
375
  end
207
376
  end
208
377
 
209
- enumerable.each_with_index.all? do |array_like_element, i|
210
- all?(array_like_element, options.trace(i), &block)
378
+ without_forcing_type do
379
+ enumerable.each_with_index.all? do |array_like_element, i|
380
+ all?(array_like_element, options.trace(i), &block)
381
+ end
211
382
  end
212
383
  end
213
384
 
385
+ def raise_forced_type_error!(type:, backtrace:)
386
+ raise CollectionTypeError, format(
387
+ 'Expected a %<expected>s, got a %<actual>s at %<backtrace>s',
388
+ expected: force,
389
+ actual: type,
390
+ backtrace: backtrace.join('->')
391
+ )
392
+ end
393
+
214
394
  def raise_empty!(backtrace:)
215
395
  raise EmptyOutputError, format('Expected output, got empty at %<backtrace>s', backtrace: backtrace.join('->'))
216
396
  end
@@ -229,10 +409,19 @@ module MediaTypes
229
409
  end
230
410
 
231
411
  exhaustive_keys = keys.dup
412
+ # noinspection RubyScope
232
413
  result = yield ->(key) { exhaustive_keys.delete(key) }
233
414
  return result if exhaustive_keys.empty?
234
415
 
235
416
  raise_exhausted!(missing_keys: exhaustive_keys, backtrace: options.backtrace)
236
417
  end
418
+
419
+ def without_forcing_type
420
+ before_force = force
421
+ self.force = nil
422
+ result = yield
423
+ self.force = before_force
424
+ result
425
+ end
237
426
  end
238
427
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'media_types/scheme'
4
+
5
+ module MediaTypes
6
+ ##
7
+ # Takes care of registering validations for a media type. It allows for nested schemes and registers each one so it
8
+ # can be looked up at a later time.
9
+ #
10
+ # @see MediaType::Dsl
11
+ # @see Scheme
12
+ #
13
+ class Validations
14
+
15
+ ##
16
+ # Creates a new stack of validations
17
+ #
18
+ # @param [Constructable] media_type a Constructable media type
19
+ # @param [Hash] registry the registry reference, or nil if top level
20
+ # @param [Scheme] scheme the current scheme or nil if top level
21
+ #
22
+ # @see MediaTypes::Dsl
23
+ # @see Constructable
24
+ # @see Scheme
25
+ #
26
+ def initialize(media_type, registry = {}, scheme = Scheme.new, &block)
27
+ self.media_type = media_type
28
+ self.registry = registry.merge!(media_type.to_s => scheme)
29
+ self.scheme = scheme
30
+
31
+ instance_exec(&block) if block_given?
32
+ end
33
+
34
+ ##
35
+ # Looks up the validations for Constructable
36
+ #
37
+ # @param [String, Constructable] media_type
38
+ # @param [lambda] default the lambda if nothing can be found
39
+ # @return [Scheme] the scheme for the given +media_type+
40
+ #
41
+ def find(media_type, default = -> { Scheme::NotStrict.new })
42
+ registry.fetch(String(media_type)) { default.call }
43
+ end
44
+
45
+ def method_missing(method_name, *arguments, &block)
46
+ if scheme.respond_to?(method_name)
47
+ return scheme.send(method_name, *arguments, &block)
48
+ end
49
+
50
+ super
51
+ end
52
+
53
+ def respond_to_missing?(method_name, include_private = false)
54
+ scheme.respond_to?(method_name) || super
55
+ end
56
+
57
+ private
58
+
59
+ attr_accessor :media_type, :registry, :scheme
60
+
61
+ ##
62
+ # Switches the inner block to a specific version
63
+ #
64
+ # @param [Numeric] version the version to switch to
65
+ #
66
+ def version(version, &block)
67
+ Validations.new(media_type.version(version), registry, &block)
68
+ end
69
+
70
+ ##
71
+ # Switches the inner block to a specific view
72
+ #
73
+ # @param [String, Symbol] view the view to switch to
74
+ #
75
+ def view(view, &block)
76
+ Validations.new(media_type.view(view), registry, &block)
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MediaTypes
4
- VERSION = '0.1.3'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/media_types.rb CHANGED
@@ -1,64 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'delegate'
4
+
3
5
  require 'media_types/version'
4
- require 'media_types/base'
6
+ require 'media_types/hash'
7
+ require 'media_types/object'
5
8
  require 'media_types/scheme'
6
-
7
- require 'delegate'
9
+ require 'media_types/dsl'
8
10
 
9
11
  module MediaTypes
12
+ # Shortcut used by #collection to #view('collection')
10
13
  COLLECTION_VIEW = 'collection'
14
+
15
+ # Shortcut used by #index to #view('index')
11
16
  INDEX_VIEW = 'index'
17
+
18
+ # Shortcut used by #create to #view('create')
12
19
  CREATE_VIEW = 'create'
13
20
 
14
21
  module_function
15
22
 
16
- def register(mime_type:, symbol: nil, synonyms: [])
23
+ ##
24
+ # Called when Registerar#register is called
25
+ # @param [Registerable] registerable
26
+ def register(registerable)
17
27
  require 'action_dispatch/http/mime_type'
18
- Mime::Type.register(mime_type, symbol, synonyms)
19
- end
20
-
21
- class Object < SimpleDelegator
22
- def class
23
- __getobj__.class
24
- end
25
28
 
26
- def ===(other)
27
- __getobj__ === other # rubocop:disable Style/CaseEquality
28
- end
29
+ mime_type = registerable.to_s
30
+ symbol = registerable.to_sym
31
+ synonyms = registerable.aliases
29
32
 
30
- def blank?
31
- if __getobj__.respond_to?(:blank?)
32
- return __getobj__.blank?
33
- end
34
-
35
- # noinspection RubySimplifyBooleanInspection
36
- __getobj__.respond_to?(:empty?) ? !!__getobj__.empty? : !__getobj__ # rubocop:disable Style/DoubleNegation
37
- end
38
-
39
- def present?
40
- !blank?
41
- end
42
- end
43
-
44
- class Hash < SimpleDelegator
45
- def class
46
- __getobj__.class
47
- end
48
-
49
- def ===(other)
50
- __getobj__ === other # rubocop:disable Style/CaseEquality
51
- end
52
-
53
- def slice(*keep_keys)
54
- if __getobj__.respond_to?(:slice)
55
- return __getobj__.slice(*keep_keys)
56
- end
57
-
58
- h = {}
59
- keep_keys.each { |key| h[key] = fetch(key) if key?(key) }
60
- h
61
- end
33
+ Mime::Type.register(mime_type, symbol, synonyms)
62
34
  end
63
35
  end
64
36
 
data/media_types.gemspec CHANGED
@@ -23,6 +23,7 @@ Gem::Specification.new do |spec|
23
23
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
24
  spec.require_paths = ['lib']
25
25
 
26
+ spec.add_development_dependency 'awesome_print'
26
27
  spec.add_development_dependency 'bundler', '~> 1.16'
27
28
  spec.add_development_dependency 'minitest', '~> 5.0'
28
29
  spec.add_development_dependency 'minitest-ci'