roda 3.2.0 → 3.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.
@@ -0,0 +1,968 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+
6
+ class Roda
7
+ module RodaPlugins
8
+ # The typecast_params plugin allows for the simple type conversion for
9
+ # submitted parameters. Submitted parameters should be considered
10
+ # untrusted input, and in standard use with browsers, parameters are
11
+ # submitted as strings (or a hash/array containing strings). In most
12
+ # cases it makes sense to explicitly convert the parameter to the
13
+ # desired type. While this can be done via manual conversion:
14
+ #
15
+ # key = request.params['key'].to_i
16
+ # key = nil unless key > 0
17
+ #
18
+ # the typecast_params plugin adds a friendlier interface:
19
+ #
20
+ # key = typecast_params.pos_int('key')
21
+ #
22
+ # As +typecast_params+ is a fairly long method name, you may want to
23
+ # consider aliasing it to something more terse in your application,
24
+ # such as +tp+.
25
+ #
26
+ # One advantage of using typecast_params is that access or conversion
27
+ # errors are raised as a specific exception class
28
+ # (+Roda::RodaPlugins::TypecastParams::Error+). This allows you to handle
29
+ # this specific exception class globally and return an appropriate 4xx
30
+ # response to the client. You can use the Error#param_name and Error#reason
31
+ # methods to get more information about the error.
32
+ #
33
+ # typecast_params offers support for default values:
34
+ #
35
+ # key = typecast_params.pos_int('key', 1)
36
+ #
37
+ # The default value is only used if no value has been submitted for the parameter,
38
+ # or if the conversion of the value results in +nil+. Handling defaults for parameter
39
+ # conversion manually is more difficult, since the parameter may not be present at all,
40
+ # or it may be present but an empty string because the user did not enter a value on
41
+ # the related form. Use of typecast_params for the conversion handles both cases.
42
+ #
43
+ # In many cases, parameters should be required, and if they aren't submitted, that
44
+ # should be considered an error. typecast_params handles this with ! methods:
45
+ #
46
+ # key = typecast_params.pos_int!('key')
47
+ #
48
+ # These ! methods raise an error instead of returning +nil+, and do not allow defaults.
49
+ #
50
+ # To make it easy to handle cases where many parameters need the same conversion
51
+ # done, you can pass an array of keys to a conversion method, and it will return an array
52
+ # of converted values:
53
+ #
54
+ # key1, key2 = typecast_params.pos_int(['key1', 'key2'])
55
+ #
56
+ # This is equivalent to:
57
+ #
58
+ # key1 = typecast_params.pos_int('key1')
59
+ # key2 = typecast_params.pos_int('key2')
60
+ #
61
+ # The ! methods also support arrays, ensuring that all parameters have a value:
62
+ #
63
+ # key1, key2 = typecast_params.pos_int!(['key1', 'key2'])
64
+ #
65
+ # For handling of array parameters, where all entries in the array use the
66
+ # same conversion, there is an +array+ method which takes the type as the first argument
67
+ # and the keys to convert as the second argument:
68
+ #
69
+ # keys = typecast_params.array(:pos_int, 'keys')
70
+ #
71
+ # If you want to ensure that all entries in the array are converted successfully and that
72
+ # there is a value for the array itself, you can use +array!+:
73
+ #
74
+ # keys = typecast_params.array!(:pos_int, 'keys')
75
+ #
76
+ # This will raise an exception if any of the values in the array for parameter +keys+ cannot
77
+ # be converted to integer.
78
+ #
79
+ # Both +array+ and +array!+ support default values which are used if no value is present
80
+ # for the parameter:
81
+ #
82
+ # keys = typecast_params.array(:pos_int, 'keys', [])
83
+ # keys = typecast_params.array!(:pos_int, 'keys', [])
84
+ #
85
+ # You can also pass an array of keys to +array+ or +array!+, if you would like to perform
86
+ # the same conversion on multiple arrays:
87
+ #
88
+ # foo_ids, bar_ids = typecast_params.array!(:pos_int, ['foo_ids', 'bar_ids'])
89
+ #
90
+ # The previous examples have shown use of the +pos_int+ method, which uses +to_i+ to convert the
91
+ # value to an integer, but returns nil if the resulting integer is not positive. Unless you need
92
+ # to handle negative numbers, it is recommended to use +pos_int+ instead of +int+ as +int+ will
93
+ # convert invalid values to 0 (since that is how <tt>String#to_i</tt> works).
94
+ #
95
+ # There are many built in methods for type conversion:
96
+ #
97
+ # any :: Returns the value as is without conversion
98
+ # str :: Raises if value is not already a string
99
+ # nonempty_str :: Raises if value is not already a string, and converts
100
+ # the empty string or string containing only whitespace to +nil+
101
+ # bool :: Converts entry to boolean if in one of the recognized formats:
102
+ # nil :: nil, ''
103
+ # true :: true, 1, '1', 't', 'true', 'yes', 'y', 'on' # case insensitive
104
+ # false :: false, 0, '0', 'f', 'false', 'no', 'n', 'off' # case insensitive
105
+ # If not in one of those formats, raises an error.
106
+ # int :: Converts value to integer using +to_i+ (note that invalid input strings will be
107
+ # returned as 0)
108
+ # pos_int :: Converts value using +to_i+, but non-positive values are converted to +nil+
109
+ # Integer :: Converts value to integer using <tt>Kernel::Integer(value, 10)</tt>
110
+ # float :: Converts value to float using +to_f+ (note that invalid input strings will be
111
+ # returned as 0.0)
112
+ # Float :: Converts value to float using <tt>Kernel::Float(value)</tt>
113
+ # Hash :: Raises if value is not already a hash
114
+ # date :: Converts value to Date using <tt>Date.parse(value)</tt>
115
+ # time :: Converts value to Time using <tt>Time.parse(value)</tt>
116
+ # datetime :: Converts value to DateTime using <tt>DateTime.parse(value)</tt>
117
+ # file :: Raises if value is not already a hash with a :tempfile key whose value
118
+ # responds to +read+ (this is the format rack uses for uploaded files).
119
+ #
120
+ # All of these methods also support ! methods (e.g. +pos_int!+), and all of them can be
121
+ # used in the +array+ and +array!+ methods to support arrays of values.
122
+ #
123
+ # Since parameter hashes can be nested, the <tt>[]</tt> method can be used to access nested
124
+ # hashes:
125
+ #
126
+ # # params: {'key'=>{'sub_key'=>'1'}}
127
+ # typecast_params['key'].pos_int!('sub_key') # => 1
128
+ #
129
+ # This works to an arbitrary depth:
130
+ #
131
+ # # params: {'key'=>{'sub_key'=>{'sub_sub_key'=>'1'}}}
132
+ # typecast_params['key']['sub_key'].pos_int!('sub_sub_key') # => 1
133
+ #
134
+ # And also works with arrays at any depth, if those arrays contain hashes:
135
+ #
136
+ # # params: {'key'=>[{'sub_key'=>{'sub_sub_key'=>'1'}}]}
137
+ # typecast_params['key'][0]['sub_key'].pos_int!('sub_sub_key') # => 1
138
+ #
139
+ # # params: {'key'=>[{'sub_key'=>['1']}]}
140
+ # typecast_params['key'][0].array!(:pos_int, 'sub_key') # => [1]
141
+ #
142
+ # To allow easier access to nested data, there is a +dig+ method:
143
+ #
144
+ # typecast_params.dig(:pos_int, 'key', 'sub_key')
145
+ # typecast_params.dig(:pos_int, 'key', 0, 'sub_key', 'sub_sub_key')
146
+ #
147
+ # +dig+ will return +nil+ if any access while looking up the nested value returns +nil+.
148
+ # There is also a +dig!+ method, which will raise an Error if +dig+ would return +nil+:
149
+ #
150
+ # typecast_params.dig!(:pos_int, 'key', 'sub_key')
151
+ # typecast_params.dig!(:pos_int, 'key', 0, 'sub_key', 'sub_sub_key')
152
+ #
153
+ # Note that none of these conversion methods modify +request.params+. They purely do the
154
+ # conversion and return the converted value. However, in some cases it is useful to do all
155
+ # the conversion up front, and then pass a hash of converted parameters to an internal
156
+ # method that expects to receive values in specific types. The +convert!+ method does
157
+ # this, and there is also a +convert_each!+ method
158
+ # designed for converting multiple values using the same block:
159
+ #
160
+ # converted_params = typecast_params.convert! do |tp|
161
+ # tp.int('page')
162
+ # tp.pos_int!('artist_id')
163
+ # tp.array!(:pos_int, 'album_ids')
164
+ # tp.convert!('sales') do |stp|
165
+ # tp.pos_int!(['num_sold', 'num_shipped'])
166
+ # end
167
+ # tp.convert!('members') do |mtp|
168
+ # mtp.convert_each! do |stp|
169
+ # stp.str!(['first_name', 'last_name'])
170
+ # end
171
+ # end
172
+ # end
173
+ #
174
+ # # converted_params:
175
+ # # {
176
+ # # 'page' => 1,
177
+ # # 'artist_id' => 2,
178
+ # # 'album_ids' => [3, 4],
179
+ # # 'sales' => {
180
+ # # 'num_sold' => 5,
181
+ # # 'num_shipped' => 6
182
+ # # },
183
+ # # 'members' => [
184
+ # # {'first_name' => 'Foo', 'last_name' => 'Bar'},
185
+ # # {'first_name' => 'Baz', 'last_name' => 'Quux'}
186
+ # # ]
187
+ # # }
188
+ #
189
+ # +convert!+ and +convert_each!+ only return values you explicitly specify for conversion
190
+ # inside the passed block.
191
+ #
192
+ # You can specify the +:symbolize+ option to +convert!+ or +convert_each!+, which will
193
+ # symbolize the resulting hash keys:
194
+ #
195
+ # converted_params = typecast_params.convert!(symbolize: true) do |tp|
196
+ # tp.int('page')
197
+ # tp.pos_int!('artist_id')
198
+ # tp.array!(:pos_int, 'album_ids')
199
+ # tp.convert!('sales') do |stp|
200
+ # tp.pos_int!(['num_sold', 'num_shipped'])
201
+ # end
202
+ # tp.convert!('members') do |mtp|
203
+ # mtp.convert_each! do |stp|
204
+ # stp.str!(['first_name', 'last_name'])
205
+ # end
206
+ # end
207
+ # end
208
+ #
209
+ # # converted_params:
210
+ # # {
211
+ # # :page => 1,
212
+ # # :artist_id => 2,
213
+ # # :album_ids => [3, 4],
214
+ # # :sales => {
215
+ # # :num_sold => 5,
216
+ # # :num_shipped => 6
217
+ # # },
218
+ # # :members => [
219
+ # # {:first_name => 'Foo', :last_name => 'Bar'},
220
+ # # {:first_name => 'Baz', :last_name => 'Quux'}
221
+ # # ]
222
+ # # }
223
+ #
224
+ # Using the +:symbolize+ option makes it simpler to transition from untrusted external
225
+ # data (string keys), to trusted data that can be used internally (trusted in the sense that
226
+ # the expected types are used).
227
+ #
228
+ # Note that if there are multiple conversion Error raised inside a +convert!+ or +convert_each!+
229
+ # block, they are recorded and a single TypecastParams::Error instance is raised after
230
+ # processing the block. TypecastParams::Error#params_names can be called on the exception to
231
+ # get an array of all parameter names with conversion issues, and TypecastParams::Error#all_errors
232
+ # can be used to get an array of all Error instances.
233
+ #
234
+ # Because of how +convert!+ and +convert_each!+ work, you should avoid calling
235
+ # TypecastParams::Params#[] inside the block you pass to these methods, because if the #[]
236
+ # call fails, it will skip the reminder of the block.
237
+ #
238
+ # Be aware that when you use +convert!+ and +convert_each!+, the conversion methods called
239
+ # inside the block may return nil if there is a error raised, and nested calls to
240
+ # +convert!+ and +convert_each!+ may not return values.
241
+ #
242
+ # When loading the typecast_params plugin, a subclass of +TypecastParams::Params+ is created
243
+ # specific to the Roda application. You can add support for custom types by passing a block
244
+ # when loading the typecast_params plugin. This block is executed in the context of the
245
+ # subclass, and calling +handle_type+ in the block can be used to add conversion methods.
246
+ # +handle_type+ accepts a type name and the block used to convert the type:
247
+ #
248
+ # plugin :typecast_params do
249
+ # handle_type(:album) do |value|
250
+ # if id = convert_pos_int(val)
251
+ # Album[id]
252
+ # end
253
+ # end
254
+ # end
255
+ #
256
+ # By default, the typecast_params conversion procs are passed the parameter value directly
257
+ # from +request.params+ without modification. In some cases, it may be beneficial to
258
+ # strip leading and trailing whitespace from parameter string values before processing, which
259
+ # you can do by passing the <tt>strip: :all</tt> option when loading the plugin.
260
+ #
261
+ # By design, typecast_params only deals with string keys, it is not possible to use
262
+ # symbol keys as arguments to the conversion methods and have them converted.
263
+ module TypecastParams
264
+ # Sentinal value for whether to raise exception during #process
265
+ CHECK_NIL = Object.new.freeze
266
+
267
+ # Exception class for errors that are caused by misuse of the API by the programmer.
268
+ # These are different from +Error+ which are raised because the submitted parameters
269
+ # do not match what is expected. Should probably be treated as a 5xx error.
270
+ class ProgrammerError < RodaError; end
271
+
272
+ # Exception class for errors that are due to the submitted parameters not matching
273
+ # what is expected. Should probably be treated as a 4xx error.
274
+ class Error < RodaError
275
+ # Set the keys in the given exception. If the exception is not already an
276
+ # instance of the class, create a new instance to wrap it.
277
+ def self.create(keys, reason, e)
278
+ if e.is_a?(self)
279
+ e.keys ||= keys
280
+ e.reason ||= reason
281
+ e
282
+ else
283
+ backtrace = e.backtrace
284
+ e = new("#{e.class}: #{e.message}")
285
+ e.keys = keys
286
+ e.reason = reason
287
+ e.set_backtrace(backtrace) if backtrace
288
+ e
289
+ end
290
+ end
291
+
292
+ # The keys used to access the parameter that caused the error. This is an array
293
+ # that can be splatted to +dig+ to get the value of the parameter causing the error.
294
+ attr_accessor :keys
295
+
296
+ # An array of all other errors that were raised with this error. If the error
297
+ # was not raised inside Params#convert! or Params#convert_each!, this will just be
298
+ # an array containing the current the receiver.
299
+ #
300
+ # This allows you to use Params#convert! to process a form input, and if any
301
+ # conversion errors occur inside the block, it can provide an array of all parameter
302
+ # names and reasons for parameters with problems.
303
+ attr_writer :all_errors
304
+
305
+ def all_errors
306
+ @all_errors ||= [self]
307
+ end
308
+
309
+ # The reason behind this error. If this error was caused by a conversion method,
310
+ # this will be the the conversion method symbol. If this error was caused
311
+ # because a value was missing, then it will be +:missing+. If this error was
312
+ # caused because a value was not the correct type, then it will be +:invalid_type+.
313
+ attr_accessor :reason
314
+
315
+ # The likely parameter name where the contents were not expected. This is
316
+ # designed for cases where the parameter was submitted with the typical
317
+ # application/x-www-form-urlencoded or multipart/form-data content types,
318
+ # and assumes the typical rack parsing of these content types into
319
+ # parameters. # If the parameters were submitted via JSON, #keys should be
320
+ # used directly.
321
+ #
322
+ # Example:
323
+ #
324
+ # # keys: ['page']
325
+ # param_name => 'page'
326
+ #
327
+ # # keys: ['artist', 'name']
328
+ # param_name => 'artist[name]'
329
+ #
330
+ # # keys: ['album', 'artist', 'name']
331
+ # param_name => 'album[artist][name]'
332
+ def param_name
333
+ if keys.length > 1
334
+ first, *rest = keys
335
+ v = first.dup
336
+ rest.each do |param|
337
+ v << "["
338
+ v << param unless param.is_a?(Integer)
339
+ v << "]"
340
+ end
341
+ v
342
+ else
343
+ keys.first
344
+ end
345
+ end
346
+
347
+ # An array of all parameter names for parameters where the context were not
348
+ # expected. If Params#convert! was not used, this will be an array containing
349
+ # #param_name. If Params#convert! was used and multiple exceptions were
350
+ # captured inside the convert! block, this will contain the parameter names
351
+ # related to all captured exceptions.
352
+ def param_names
353
+ all_errors.map(&:param_name)
354
+ end
355
+ end
356
+
357
+ module StringStripper
358
+ private
359
+
360
+ # Strip any resulting input string.
361
+ def param_value(key)
362
+ v = super
363
+
364
+ if v.is_a?(String)
365
+ v = v.strip
366
+ end
367
+
368
+ v
369
+ end
370
+ end
371
+
372
+ # Class handling conversion of submitted parameters to desired types.
373
+ class Params
374
+ # Handle conversions for the given type using the given block.
375
+ # For a type named +foo+, this will create the following methods:
376
+ #
377
+ # * foo(key, default=nil)
378
+ # * foo!(key)
379
+ # * convert_foo(value) # private
380
+ # * _convert_array_foo(value) # private
381
+ #
382
+ # This method is used to define all type conversions, even the built
383
+ # in ones. It can be called in subclasses to setup subclass-specific
384
+ # types.
385
+ def self.handle_type(type, &block)
386
+ convert_meth = :"convert_#{type}"
387
+ define_method(convert_meth, &block)
388
+
389
+ convert_array_meth = :"_convert_array_#{type}"
390
+ define_method(convert_array_meth) do |v|
391
+ raise Error, "expected array but received #{v.inspect}" unless v.is_a?(Array)
392
+ v.map!{|val| send(convert_meth, val)}
393
+ end
394
+
395
+ private convert_meth, convert_array_meth
396
+
397
+ define_method(type) do |key, default=nil|
398
+ process_arg(convert_meth, key, default) if require_hash!
399
+ end
400
+
401
+ define_method(:"#{type}!") do |key|
402
+ send(type, key, CHECK_NIL)
403
+ end
404
+ end
405
+
406
+ # Create a new instance with the given object and nesting level.
407
+ # +obj+ should be an array or hash, and +nesting+ should be an
408
+ # array. Designed for internal use, should not be called by
409
+ # external code.
410
+ def self.nest(obj, nesting)
411
+ v = allocate
412
+ v.instance_variable_set(:@nesting, nesting)
413
+ v.send(:initialize, obj)
414
+ v
415
+ end
416
+
417
+ handle_type(:any) do |v|
418
+ v
419
+ end
420
+
421
+ handle_type(:str) do |v|
422
+ raise Error, "expected string but received #{v.inspect}" unless v.is_a?(::String)
423
+ v
424
+ end
425
+
426
+ handle_type(:nonempty_str) do |v|
427
+ if (v = convert_str(v)) && !v.strip.empty?
428
+ v
429
+ end
430
+ end
431
+
432
+ handle_type(:bool) do |v|
433
+ case v
434
+ when ''
435
+ nil
436
+ when false, 0, /\A(?:0|f(?:alse)?|no?|off)\z/i
437
+ false
438
+ when true, 1, /\A(?:1|t(?:rue)?|y(?:es)?|on)\z/i
439
+ true
440
+ else
441
+ raise Error, "expected bool but received #{v.inspect}"
442
+ end
443
+ end
444
+
445
+ handle_type(:int) do |v|
446
+ string_or_numeric!(v) && v.to_i
447
+ end
448
+
449
+ handle_type(:pos_int) do |v|
450
+ if (v = convert_int(v)) && v > 0
451
+ v
452
+ end
453
+ end
454
+
455
+ handle_type(:Integer) do |v|
456
+ string_or_numeric!(v) && ::Kernel::Integer(v, 10)
457
+ end
458
+
459
+ handle_type(:float) do |v|
460
+ string_or_numeric!(v) && v.to_f
461
+ end
462
+
463
+ handle_type(:Float) do |v|
464
+ string_or_numeric!(v) && ::Kernel::Float(v)
465
+ end
466
+
467
+ handle_type(:Hash) do |v|
468
+ raise Error, "expected hash but received #{v.inspect}" unless v.is_a?(::Hash)
469
+ v
470
+ end
471
+
472
+ handle_type(:date) do |v|
473
+ parse!(::Date, v)
474
+ end
475
+
476
+ handle_type(:time) do |v|
477
+ parse!(::Time, v)
478
+ end
479
+
480
+ handle_type(:datetime) do |v|
481
+ parse!(::DateTime, v)
482
+ end
483
+
484
+ handle_type(:file) do |v|
485
+ raise Error, "expected hash with :tempfile entry" unless v.is_a?(::Hash) && v.has_key?(:tempfile) && v[:tempfile].respond_to?(:read)
486
+ v
487
+ end
488
+
489
+ # Set the object used for converting. Conversion methods will convert members of
490
+ # the passed object.
491
+ def initialize(obj)
492
+ case @obj = obj
493
+ when Hash, Array
494
+ # nothing
495
+ else
496
+ if @nesting
497
+ handle_error(nil, (@obj.nil? ? :missing : :invalid_type), "value of #{param_name(nil)} parameter not an array or hash: #{obj.inspect}", true)
498
+ else
499
+ handle_error(nil, :invalid_type, "parameters given not an array or hash: #{obj.inspect}", true)
500
+ end
501
+ end
502
+ end
503
+
504
+ # If key is a String Return whether the key is present in the object,
505
+ def present?(key)
506
+ case key
507
+ when String
508
+ !any(key).nil?
509
+ when Array
510
+ key.all? do |k|
511
+ raise ProgrammerError, "non-String element in array argument passed to present?: #{k.inspect}" unless k.is_a?(String)
512
+ !any(k).nil?
513
+ end
514
+ else
515
+ raise ProgrammerError, "unexpected argument passed to present?: #{key.inspect}"
516
+ end
517
+ end
518
+
519
+ # Return a new Params instance for the given +key+. The value of +key+ should be an array
520
+ # if +key+ is an integer, or hash otherwise.
521
+ def [](key)
522
+ @subs ||= {}
523
+ if sub = @subs[key]
524
+ return sub
525
+ end
526
+
527
+ if @obj.is_a?(Array)
528
+ unless key.is_a?(Integer)
529
+ handle_error(key, :invalid_type, "invalid use of non-integer key for accessing array: #{key.inspect}", true)
530
+ end
531
+ else
532
+ if key.is_a?(Integer)
533
+ handle_error(key, :invalid_type, "invalid use of integer key for accessing hash: #{key}", true)
534
+ end
535
+ end
536
+
537
+ v = @obj[key]
538
+ v = yield if v.nil? && block_given?
539
+
540
+ begin
541
+ sub = self.class.nest(v, Array(@nesting) + [key])
542
+ rescue => e
543
+ handle_error(key, :invalid_type, e, true)
544
+ end
545
+
546
+ @subs[key] = sub
547
+ sub.sub_capture(@capture, @symbolize)
548
+ sub
549
+ end
550
+
551
+ # Return the nested value for key. If there is no nested_value for +key+,
552
+ # calls the block to return the value, or returns nil if there is no block given.
553
+ def fetch(key)
554
+ send(:[], key){return(yield if block_given?)}
555
+ end
556
+
557
+ # Captures conversions inside the given block, and returns a hash of all conversions,
558
+ # including conversions of subkeys. +keys+ should be an array of subkeys to access,
559
+ # or nil to convert the current object. If +keys+ is given as a hash, it is used as
560
+ # the options hash. Options:
561
+ #
562
+ # :symbolize :: Convert any string keys in the resulting hash and for any
563
+ # conversions below
564
+ def convert!(keys=nil, opts=OPTS)
565
+ if keys.is_a?(Hash)
566
+ opts = keys
567
+ keys = nil
568
+ end
569
+
570
+ _capture!(:nested_params, opts) do
571
+ if sub = subkey(Array(keys).dup, true)
572
+ yield sub
573
+ end
574
+ end
575
+ end
576
+
577
+ # Runs convert! for each key specified by the :keys option. If no :keys option is given and the object is an array,
578
+ # runs convert! for all entries in the array.
579
+ # Raises an Error if the current object is not an array and :keys option is not specified.
580
+ # Passes any options given to #convert!. Options:
581
+ #
582
+ # :keys :: The keys to extract from the object
583
+ def convert_each!(opts=OPTS, &block)
584
+ np = !@capture
585
+
586
+ _capture!(nil, opts) do
587
+ unless keys = opts[:keys]
588
+ unless @obj.is_a?(Array)
589
+ handle_error(nil, :invalid_type, "convert_each! called on non-array")
590
+ next
591
+ end
592
+ keys = (0...@obj.length)
593
+ end
594
+
595
+ keys.map do |i|
596
+ begin
597
+ if v = subkey([i], true)
598
+ yield v
599
+ v.nested_params if np
600
+ end
601
+ rescue => e
602
+ handle_error(i, :invalid_type, e)
603
+ end
604
+ end
605
+ end
606
+ end
607
+
608
+ # Convert values nested under the current obj. Traverses the current object using +nest+, then converts
609
+ # +key+ on that object using +type+:
610
+ #
611
+ # tp.dig(:pos_int, 'foo') # tp.pos_int('foo')
612
+ # tp.dig(:pos_int, 'foo', 'bar') # tp['foo'].pos_int('bar')
613
+ # tp.dig(:pos_int, 'foo', 'bar', 'baz') # tp['foo']['bar'].pos_int('baz')
614
+ #
615
+ # Returns nil if any of the values are not present or not the expected type. If the nest path results
616
+ # in an object that is not an array or hash, then raises an Error.
617
+ #
618
+ # You can use +dig+ to get access to nested arrays by using <tt>:array</tt> or <tt>:array!</tt> as
619
+ # the first argument and providing the type in the second argument:
620
+ #
621
+ # tp.dig(:array, :pos_int, 'foo', 'bar', 'baz') # tp['foo']['bar'].array(:int, 'baz')
622
+ def dig(type, *nest, key)
623
+ _dig(false, type, nest, key)
624
+ end
625
+
626
+ # Similar to +dig+, but raises an Error instead of returning +nil+ if no value is found.
627
+ def dig!(type, *nest, key)
628
+ _dig(true, type, nest, key)
629
+ end
630
+
631
+ # Convert the value of +key+ to an array of values of the given +type+. If +default+ is
632
+ # given, any +nil+ values in the array are replaced with +default+. If +key+ is an array
633
+ # then this returns an array of arrays, one for each respective value of +key+. If there is
634
+ # no value for +key+, nil is returned instead of an array.
635
+ def array(type, key, default=nil)
636
+ meth = :"_convert_array_#{type}"
637
+ raise ProgrammerError, "no typecast_params type registered for #{type.inspect}" unless respond_to?(meth, true)
638
+ process_arg(meth, key, default) if require_hash!
639
+ end
640
+
641
+ # Call +array+ with the +type+, +key+, and +default+, but if the return value is nil or any value in
642
+ # the returned array is +nil+, raise an Error.
643
+ def array!(type, key, default=nil)
644
+ v = array(type, key, default)
645
+
646
+ if key.is_a?(Array)
647
+ key.zip(v).each do |key, arr|
648
+ check_array!(key, arr)
649
+ end
650
+ else
651
+ check_array!(key, v)
652
+ end
653
+
654
+ v
655
+ end
656
+
657
+ protected
658
+
659
+ # Recursively descendent into all known subkeys and get the converted params from each.
660
+ def nested_params
661
+ params = @params
662
+
663
+ if @subs
664
+ @subs.each do |key, v|
665
+ if key.is_a?(String) && symbolize?
666
+ key = key.to_sym
667
+ end
668
+ params[key] = v.nested_params
669
+ end
670
+ end
671
+
672
+ params
673
+ end
674
+
675
+ # Recursive method to get subkeys.
676
+ def subkey(keys, do_raise)
677
+ unless key = keys.shift
678
+ return self
679
+ end
680
+
681
+ reason = :invalid_type
682
+
683
+ case key
684
+ when String
685
+ unless @obj.is_a?(Hash)
686
+ raise Error, "parameter #{param_name(nil)} is not a hash" if do_raise
687
+ return
688
+ end
689
+ present = @obj.has_key?(key)
690
+ when Integer
691
+ unless @obj.is_a?(Array)
692
+ raise Error, "parameter #{param_name(nil)} is not an array" if do_raise
693
+ return
694
+ end
695
+ present = key < @obj.length
696
+ else
697
+ raise ProgrammerError, "invalid argument used to traverse parameters: #{key.inspect}"
698
+ end
699
+
700
+ unless present
701
+ reason = :missing
702
+ raise Error, "parameter #{param_name(key)} is not present" if do_raise
703
+ return
704
+ end
705
+
706
+ if v = self[key]
707
+ v.subkey(keys, do_raise)
708
+ end
709
+ rescue => e
710
+ handle_error(key, reason, e)
711
+ end
712
+
713
+ # Inherit given capturing and symbolize setting from parent object.
714
+ def sub_capture(capture, symbolize)
715
+ if @capture = capture
716
+ @symbolize = symbolize
717
+ @params = @obj.class.new
718
+ end
719
+ end
720
+
721
+ private
722
+
723
+ # Whether to symbolize keys when capturing. Note that the method
724
+ # is renamed to +symbolize?+.
725
+ attr_reader :symbolize
726
+ alias symbolize? symbolize
727
+ undef symbolize
728
+
729
+ # Internals of convert! and convert_each!.
730
+ def _capture!(ret, opts)
731
+ unless cap = @capture
732
+ @params = @obj.class.new
733
+ @subs.clear if @subs
734
+ capturing_started = true
735
+ cap = @capture = []
736
+ end
737
+
738
+ if opts.has_key?(:symbolize)
739
+ @symbolize = !!opts[:symbolize]
740
+ end
741
+
742
+ begin
743
+ v = yield
744
+ rescue Error => e
745
+ cap << e unless cap.last == e
746
+ end
747
+
748
+ if capturing_started
749
+ unless cap.empty?
750
+ e = cap[0]
751
+ e.all_errors = cap
752
+ raise e
753
+ end
754
+
755
+ if ret == :nested_params
756
+ nested_params
757
+ else
758
+ v
759
+ end
760
+ end
761
+ ensure
762
+ # Only unset capturing if capturing was not already started.
763
+ if capturing_started
764
+ @capture = nil
765
+ end
766
+ end
767
+
768
+ # Raise an error if the array given does contains nil values.
769
+ def check_array!(key, arr)
770
+ if arr
771
+ if arr.any?{|val| val.nil?}
772
+ handle_error(key, :invalid_type, "invalid value in array parameter #{param_name(key)}")
773
+ end
774
+ else
775
+ handle_error(key, :missing, "missing parameter for #{param_name(key)}")
776
+ end
777
+ end
778
+
779
+ # Internals of dig/dig!
780
+ def _dig(force, type, nest, key)
781
+ if type == :array || type == :array!
782
+ conv_type = nest.shift
783
+ unless conv_type.is_a?(Symbol)
784
+ raise ProgrammerError, "incorrect subtype given when using #{type} as argument for dig/dig!: #{conv_type.inspect}"
785
+ end
786
+ meth = type
787
+ type = conv_type
788
+ args = [meth, type]
789
+ else
790
+ meth = type
791
+ args = [type]
792
+ end
793
+
794
+ unless respond_to?("_convert_array_#{type}", true)
795
+ raise ProgrammerError, "no typecast_params type registered for #{meth.inspect}"
796
+ end
797
+
798
+ if v = subkey(nest, force)
799
+ v.send(*args, key, (CHECK_NIL if force))
800
+ end
801
+ end
802
+
803
+ # Format a reasonable parameter name value, for use in exception messages.
804
+ def param_name(key)
805
+ first, *rest = keys(key)
806
+ if first
807
+ v = first.dup
808
+ rest.each do |param|
809
+ v << "[#{param}]"
810
+ end
811
+ v
812
+ end
813
+ end
814
+
815
+ # If +key+ is not +nil+, add it to the given nesting. Otherwise, just return the given nesting.
816
+ # Designed for use in setting the +keys+ values in raised exceptions.
817
+ def keys(key)
818
+ Array(@nesting) + Array(key)
819
+ end
820
+
821
+ # Handle any conversion errors. By default, reraises Error instances with the keys set,
822
+ # converts ::ArgumentError instances to Error instances, and reraises other exceptions.
823
+ def handle_error(key, reason, e, do_raise=false)
824
+ case e
825
+ when String
826
+ handle_error(key, reason, Error.new(e), do_raise=false)
827
+ when Error, ArgumentError
828
+ if @capture && (le = @capture.last) && le == e
829
+ raise e if do_raise
830
+ return
831
+ end
832
+
833
+ e = Error.create(keys(key), reason, e)
834
+
835
+ if @capture
836
+ @capture << e
837
+ raise e if do_raise
838
+ nil
839
+ else
840
+ raise e
841
+ end
842
+ else
843
+ raise e
844
+ end
845
+ end
846
+
847
+ # Issue an error unless the current object is a hash. Used to ensure we don't try to access
848
+ # entries if the current object is an array.
849
+ def require_hash!
850
+ @obj.is_a?(Hash) || handle_error(nil, :invalid_type, "expected hash object in #{param_name(nil)} but received array object")
851
+ end
852
+
853
+ # If +key+ is not an array, convert the value at the given +key+ using the +meth+ method and +default+
854
+ # value. If +key+ is an array, return an array with the conversion done for each respective member of +key+.
855
+ def process_arg(meth, key, default)
856
+ case key
857
+ when String
858
+ v = process(meth, key, default)
859
+
860
+ if @capture
861
+ key = key.to_sym if symbolize?
862
+ @params[key] = v
863
+ end
864
+
865
+ v
866
+ when Array
867
+ key.map do |k|
868
+ raise ProgrammerError, "non-String element in array argument passed to typecast_params: #{k.inspect}" unless k.is_a?(String)
869
+ process_arg(meth, k, default)
870
+ end
871
+ else
872
+ raise ProgrammerError, "Unsupported argument for typecast_params conversion method: #{key.inspect}"
873
+ end
874
+ end
875
+
876
+ # Get the value of +key+ for the object, and convert it to the expected type using +meth+.
877
+ # If the value either before or after conversion is nil, return the +default+ value.
878
+ def process(meth, key, default)
879
+ v = param_value(key)
880
+
881
+ unless v.nil?
882
+ v = send(meth, v)
883
+ end
884
+
885
+ if v.nil?
886
+ if default == CHECK_NIL
887
+ handle_error(key, :missing, "missing parameter for #{param_name(key)}")
888
+ end
889
+
890
+ default
891
+ else
892
+ v
893
+ end
894
+ rescue => e
895
+ handle_error(key, meth.to_s.sub(/\A_?convert_/, '').to_sym, e)
896
+ end
897
+
898
+ # Get the value for the given key in the object.
899
+ def param_value(key)
900
+ @obj[key]
901
+ end
902
+
903
+ # Helper for conversion methods where '' should be considered nil,
904
+ # and only String or Numeric values should be converted.
905
+ def string_or_numeric!(v)
906
+ case v
907
+ when ''
908
+ nil
909
+ when String, Numeric
910
+ true
911
+ else
912
+ raise Error, "unexpected value received: #{v.inspect}"
913
+ end
914
+ end
915
+
916
+ # Helper for conversion methods where '' should be considered nil,
917
+ # and only String values should be converted by calling +parse+ on
918
+ # the given +klass+.
919
+ def parse!(klass, v)
920
+ case v
921
+ when ''
922
+ nil
923
+ when String
924
+ klass.parse(v)
925
+ else
926
+ raise Error, "unexpected value received: #{v.inspect}"
927
+ end
928
+ end
929
+ end
930
+
931
+ # Set application-specific Params subclass unless one has been set,
932
+ # and if a block is passed, eval it in the context of the subclass.
933
+ # Respect the <tt>strip: :all</tt> to strip all parameter strings
934
+ # before processing them.
935
+ def self.configure(app, opts=OPTS, &block)
936
+ app.const_set(:TypecastParams, Class.new(RodaPlugins::TypecastParams::Params)) unless app.const_defined?(:TypecastParams)
937
+ app::TypecastParams.class_eval(&block) if block
938
+ if opts[:strip] == :all
939
+ app::TypecastParams.send(:include, StringStripper)
940
+ end
941
+ end
942
+
943
+ module ClassMethods
944
+ # Freeze the Params subclass when freezing the class.
945
+ def freeze
946
+ self::TypecastParams.freeze
947
+ super
948
+ end
949
+
950
+ # Assign the application subclass a subclass of the current Params subclass.
951
+ def inherited(subclass)
952
+ super
953
+ subclass.const_set(:TypecastParams, Class.new(self::TypecastParams))
954
+ end
955
+ end
956
+
957
+ module InstanceMethods
958
+ # Return and cache the instance of the Params class for the current request.
959
+ # Type conversion methods will be called on the result of this method.
960
+ def typecast_params
961
+ @_typecast_params ||= self.class::TypecastParams.new(@_request.params)
962
+ end
963
+ end
964
+ end
965
+
966
+ register_plugin(:typecast_params, TypecastParams)
967
+ end
968
+ end