media_types 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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'