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.
- checksums.yaml +4 -4
- data/CHANGELOG +4 -0
- data/README.rdoc +29 -1
- data/doc/release_notes/3.3.0.txt +291 -0
- data/lib/roda/plugins/class_matchers.rb +1 -1
- data/lib/roda/plugins/indifferent_params.rb +8 -1
- data/lib/roda/plugins/param_matchers.rb +11 -4
- data/lib/roda/plugins/path.rb +5 -2
- data/lib/roda/plugins/symbol_status.rb +2 -2
- data/lib/roda/plugins/typecast_params.rb +968 -0
- data/lib/roda/version.rb +1 -1
- data/spec/plugin/public_spec.rb +18 -0
- data/spec/plugin/typecast_params_spec.rb +1215 -0
- metadata +6 -2
@@ -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
|