roda 3.2.0 → 3.3.0

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