# frozen-string-literal: true
require 'date'
require 'time'
class Roda
module RodaPlugins
# The typecast_params plugin allows for type conversion of submitted parameters.
# Submitted parameters should be considered untrusted input, and in standard use
# with browsers, parameters are # submitted as strings (or a hash/array containing
# strings). In most # cases it makes sense to explicitly convert the parameter to the
# desired type. While this can be done via manual conversion:
#
# val = request.params['key'].to_i
# val = nil unless val > 0
#
# the typecast_params plugin adds a friendlier interface:
#
# val = typecast_params.pos_int('key')
#
# As +typecast_params+ is a fairly long method name, and may be a method you call
# frequently, you may want to consider aliasing it to something more terse in your
# application, such as +tp+.
#
# typecast_params offers support for default values:
#
# val = typecast_params.pos_int('key', 1)
#
# The default value is only used if no value has been submitted for the parameter,
# or if the conversion of the value results in +nil+. Handling defaults for parameter
# conversion manually is more difficult, since the parameter may not be present at all,
# or it may be present but an empty string because the user did not enter a value on
# the related form. Use of typecast_params for the conversion handles both cases.
#
# In many cases, parameters should be required, and if they aren't submitted, that
# should be considered an error. typecast_params handles this with ! methods:
#
# val = typecast_params.pos_int!('key')
#
# These ! methods raise an error instead of returning +nil+, and do not allow defaults.
#
# The errors raised by this plugin use a specific exception class,
# +Roda::RodaPlugins::TypecastParams::Error+. This allows you to handle
# this specific exception class globally and return an appropriate 4xx
# response to the client. You can use the Error#param_name and Error#reason
# methods to get more information about the error.
#
# To make it easy to handle cases where many parameters need the same conversion
# done, you can pass an array of keys to a conversion method, and it will return an array
# of converted values:
#
# val1, val2 = typecast_params.pos_int(['key1', 'key2'])
#
# This is equivalent to:
#
# val1 = typecast_params.pos_int('key1')
# val2 = typecast_params.pos_int('key2')
#
# The ! methods also support arrays, ensuring that all parameters have a value:
#
# val1, val2 = typecast_params.pos_int!(['key1', 'key2'])
#
# For handling of array parameters, where all entries in the array use the
# same conversion, there is an +array+ method which takes the type as the first argument
# and the keys to convert as the second argument:
#
# vals = typecast_params.array(:pos_int, 'keys')
#
# If you want to ensure that all entries in the array are converted successfully and that
# there is a value for the array itself, you can use +array!+:
#
# vals = typecast_params.array!(:pos_int, 'keys')
#
# This will raise an exception if any of the values in the array for parameter +keys+ cannot
# be converted to integer.
#
# Both +array+ and +array!+ support default values which are used if no value is present
# for the parameter:
#
# vals1 = typecast_params.array(:pos_int, 'keys1', [])
# vals2 = typecast_params.array!(:pos_int, 'keys2', [])
#
# You can also pass an array of keys to +array+ or +array!+, if you would like to perform
# the same conversion on multiple arrays:
#
# foo_ids, bar_ids = typecast_params.array!(:pos_int, ['foo_ids', 'bar_ids'])
#
# The previous examples have shown use of the +pos_int+ method, which uses +to_i+ to convert the
# value to an integer, but returns +nil+ if the resulting integer is not positive. Unless you need
# to handle negative numbers, it is recommended to use +pos_int+ instead of +int+ as +int+ will
# convert invalid values to 0 (since that is how <tt>String#to_i</tt> works).
#
# There are many built in methods for type conversion:
#
# any :: Returns the value as is without conversion
# str :: Raises if value is not already a string
# nonempty_str :: Raises if value is not already a string, and converts
# the empty string or string containing only whitespace to +nil+
# bool :: Converts entry to boolean if in one of the recognized formats:
# nil :: nil, ''
# true :: true, 1, '1', 't', 'true', 'yes', 'y', 'on' # case insensitive
# false :: false, 0, '0', 'f', 'false', 'no', 'n', 'off' # case insensitive
# If not in one of those formats, raises an error.
# int :: Converts value to integer using +to_i+ (note that invalid input strings will be
# returned as 0)
# pos_int :: Converts value using +to_i+, but non-positive values are converted to +nil+
# Integer :: Converts value to integer using <tt>Kernel::Integer</tt>, with base 10 for
# string inputs, and a check that the output value is equal to the input
# value for numeric inputs.
# float :: Converts value to float using +to_f+ (note that invalid input strings will be
# returned as 0.0)
# Float :: Converts value to float using <tt>Kernel::Float(value)</tt>
# Hash :: Raises if value is not already a hash
# date :: Converts value to Date using <tt>Date.parse(value)</tt>
# time :: Converts value to Time using <tt>Time.parse(value)</tt>
# datetime :: Converts value to DateTime using <tt>DateTime.parse(value)</tt>
# file :: Raises if value is not already a hash with a :tempfile key whose value
# responds to +read+ (this is the format rack uses for uploaded files).
#
# All of these methods also support ! methods (e.g. +pos_int!+), and all of them can be
# used in the +array+ and +array!+ methods to support arrays of values.
#
# Since parameter hashes can be nested, the <tt>[]</tt> method can be used to access nested
# hashes:
#
# # params: {'key'=>{'sub_key'=>'1'}}
# typecast_params['key'].pos_int!('sub_key') # => 1
#
# This works to an arbitrary depth:
#
# # params: {'key'=>{'sub_key'=>{'sub_sub_key'=>'1'}}}
# typecast_params['key']['sub_key'].pos_int!('sub_sub_key') # => 1
#
# And also works with arrays at any depth, if those arrays contain hashes:
#
# # params: {'key'=>[{'sub_key'=>{'sub_sub_key'=>'1'}}]}
# typecast_params['key'][0]['sub_key'].pos_int!('sub_sub_key') # => 1
#
# # params: {'key'=>[{'sub_key'=>['1']}]}
# typecast_params['key'][0].array!(:pos_int, 'sub_key') # => [1]
#
# To allow easier access to nested data, there is a +dig+ method:
#
# typecast_params.dig(:pos_int, 'key', 'sub_key')
# typecast_params.dig(:pos_int, 'key', 0, 'sub_key', 'sub_sub_key')
#
# +dig+ will return +nil+ if any access while looking up the nested value returns +nil+.
# There is also a +dig!+ method, which will raise an Error if +dig+ would return +nil+:
#
# typecast_params.dig!(:pos_int, 'key', 'sub_key')
# typecast_params.dig!(:pos_int, 'key', 0, 'sub_key', 'sub_sub_key')
#
# Note that none of these conversion methods modify +request.params+. They purely do the
# conversion and return the converted value. However, in some cases it is useful to do all
# the conversion up front, and then pass a hash of converted parameters to an internal
# method that expects to receive values in specific types. The +convert!+ method does
# this, and there is also a +convert_each!+ method
# designed for converting multiple values using the same block:
#
# converted_params = typecast_params.convert! do |tp|
# tp.int('page')
# tp.pos_int!('artist_id')
# tp.array!(:pos_int, 'album_ids')
# tp.convert!('sales') do |stp|
# stp.pos_int!(['num_sold', 'num_shipped'])
# end
# tp.convert!('members') do |mtp|
# mtp.convert_each! do |stp|
# stp.str!(['first_name', 'last_name'])
# end
# end
# end
#
# # converted_params:
# # {
# # 'page' => 1,
# # 'artist_id' => 2,
# # 'album_ids' => [3, 4],
# # 'sales' => {
# # 'num_sold' => 5,
# # 'num_shipped' => 6
# # },
# # 'members' => [
# # {'first_name' => 'Foo', 'last_name' => 'Bar'},
# # {'first_name' => 'Baz', 'last_name' => 'Quux'}
# # ]
# # }
#
# +convert!+ and +convert_each!+ only return values you explicitly specify for conversion
# inside the passed block.
#
# You can specify the +:symbolize+ option to +convert!+ or +convert_each!+, which will
# symbolize the resulting hash keys:
#
# converted_params = typecast_params.convert!(symbolize: true) do |tp|
# tp.int('page')
# tp.pos_int!('artist_id')
# tp.array!(:pos_int, 'album_ids')
# tp.convert!('sales') do |stp|
# stp.pos_int!(['num_sold', 'num_shipped'])
# end
# tp.convert!('members') do |mtp|
# mtp.convert_each! do |stp|
# stp.str!(['first_name', 'last_name'])
# end
# end
# end
#
# # converted_params:
# # {
# # :page => 1,
# # :artist_id => 2,
# # :album_ids => [3, 4],
# # :sales => {
# # :num_sold => 5,
# # :num_shipped => 6
# # },
# # :members => [
# # {:first_name => 'Foo', :last_name => 'Bar'},
# # {:first_name => 'Baz', :last_name => 'Quux'}
# # ]
# # }
#
# Using the +:symbolize+ option makes it simpler to transition from untrusted external
# data (string keys), to semitrusted data that can be used internally (trusted in the sense that
# the expected types are used, not that you trust the values).
#
# Note that if there are multiple conversion errors raised inside a +convert!+ or +convert_each!+
# block, they are recorded and a single TypecastParams::Error instance is raised after
# processing the block. TypecastParams::Error#param_names can be called on the exception to
# get an array of all parameter names with conversion issues, and TypecastParams::Error#all_errors
# can be used to get an array of all Error instances.
#
# Because of how +convert!+ and +convert_each!+ work, you should avoid calling
# TypecastParams::Params#[] inside the block you pass to these methods, because if the #[]
# call fails, it will skip the reminder of the block.
#
# Be aware that when you use +convert!+ and +convert_each!+, the conversion methods called
# inside the block may return nil if there is a error raised, and nested calls to
# +convert!+ and +convert_each!+ may not return values.
#
# When loading the typecast_params plugin, a subclass of +TypecastParams::Params+ is created
# specific to the Roda application. You can add support for custom types by passing a block
# when loading the typecast_params plugin. This block is executed in the context of the
# subclass, and calling +handle_type+ in the block can be used to add conversion methods.
# +handle_type+ accepts a type name, an options hash, and the block used to convert the type.
# The only currently supported option is +:max_input_bytesize+, specifying the maximum bytesize of
# string input. You can also override the max input bytesize of an existing type using the
# +max_input_bytesize+ method.
#
# plugin :typecast_params do
# handle_type(:album, max_input_bytesize: 100) do |value|
# if id = convert_pos_int(val)
# Album[id]
# end
# end
# max_input_bytesize(:date, 256)
# end
#
# By default, the typecast_params conversion procs are passed the parameter value directly
# from +request.params+ without modification. In some cases, it may be beneficial to
# strip leading and trailing whitespace from parameter string values before processing, which
# you can do by passing the <tt>strip: :all</tt> option when loading the plugin.
#
# By default, the typecasting methods for some types check whether the bytesize of input
# strings is over the maximum expected values, and raise an error in such cases. The input
# bytesize is checked prior to any type conversion. If you would like to skip this check
# and allow any bytesize when doing type conversion for param string values, you can do so by
# passing the # <tt>:skip_bytesize_checking</tt> option when loading the plugin. By default,
# there is an 100 byte limit on integer input, an 1000 byte input on float input, and a 128
# byte limit on date/time input.
#
# By default, the typecasting methods check whether input strings have null bytes, and raise
# an error in such cases. This check for null bytes occurs prior to any type conversion.
# If you would like to skip this check and allow null bytes in param string values,
# you can do so by passing the <tt>:allow_null_bytes</tt> option when loading the plugin.
#
# You can use the :date_parse_input_handler option to specify custom handling of date
# parsing input. Modern versions of Ruby and the date gem internally raise if the input to
# date parsing methods is too large to prevent denial of service. If you are using an
# older version of Ruby, you can use this option to enforce the same check:
#
# plugin :typecast_params, date_parse_input_handler: proc {|string|
# raise ArgumentError, "too big" if string.bytesize > 128
# string
# }
#
# You can also use this option to modify the input, such as truncating it to the first
# 128 bytes:
#
# plugin :typecast_params, date_parse_input_handler: proc {|string|
# string.b[0, 128]
# }
#
# The +date_parse_input_handler+ is only called if the value is under the max input
# bytesize, so you may need to call +max_input_bytesize+ for the +:date+, +:time+, and
# +:datetime+ methods to override the max input bytesize if you want to use this option
# for input strings over 128 bytes.
#
# By design, typecast_params only deals with string keys, it is not possible to use
# symbol keys as arguments to the conversion methods and have them converted.
module TypecastParams
# Sentinal value for whether to raise exception during #process
CHECK_NIL = Object.new.freeze
# Exception class for errors that are caused by misuse of the API by the programmer.
# These are different from +Error+ which are raised because the submitted parameters
# do not match what is expected. Should probably be treated as a 5xx error.
class ProgrammerError < RodaError; end
# Exception class for errors that are due to the submitted parameters not matching
# what is expected. Should probably be treated as a 4xx error.
class Error < RodaError
# Set the keys in the given exception. If the exception is not already an
# instance of the class, create a new instance to wrap it.
def self.create(keys, reason, e)
if e.is_a?(self)
e.keys ||= keys
e.reason ||= reason
e
else
backtrace = e.backtrace
e = new("#{e.class}: #{e.message}")
e.keys = keys
e.reason = reason
e.set_backtrace(backtrace) if backtrace
e
end
end
# The keys used to access the parameter that caused the error. This is an array
# that can be splatted to +dig+ to get the value of the parameter causing the error.
attr_accessor :keys
# An array of all other errors that were raised with this error. If the error
# was not raised inside Params#convert! or Params#convert_each!, this will just be
# an array containing the current receiver.
#
# This allows you to use Params#convert! to process a form input, and if any
# conversion errors occur inside the block, it can provide an array of all parameter
# names and reasons for parameters with problems.
attr_writer :all_errors
def all_errors
@all_errors ||= [self]
end
# The reason behind this error. If this error was caused by a conversion method,
# this will be the conversion method symbol. If this error was caused
# because a value was missing, then it will be +:missing+. If this error was
# caused because a value was not the correct type, then it will be +:invalid_type+.
attr_accessor :reason
# The likely parameter name where the contents were not expected. This is
# designed for cases where the parameter was submitted with the typical
# application/x-www-form-urlencoded or multipart/form-data content types,
# and assumes the typical rack parsing of these content types into
# parameters. # If the parameters were submitted via JSON, #keys should be
# used directly.
#
# Example:
#
# # keys: ['page']
# param_name => 'page'
#
# # keys: ['artist', 'name']
# param_name => 'artist[name]'
#
# # keys: ['album', 'artist', 'name']
# param_name => 'album[artist][name]'
def param_name
if keys.length > 1
first, *rest = keys
v = first.dup
rest.each do |param|
v << "["
v << param unless param.is_a?(Integer)
v << "]"
end
v
else
keys.first
end
end
# An array of all parameter names for parameters where the context were not
# expected. If Params#convert! was not used, this will be an array containing
# #param_name. If Params#convert! was used and multiple exceptions were
# captured inside the convert! block, this will contain the parameter names
# related to all captured exceptions.
def param_names
all_errors.map(&:param_name)
end
end
module AllowNullByte
private
# Allow ASCII NUL bytes ("\0") in parameter string values.
def check_null_byte(v)
end
end
module StringStripper
private
# Strip any resulting input string.
def param_value(key)
v = super
if v.is_a?(String)
v = v.strip
end
v
end
end
module DateParseInputHandler
# Pass input string to date parsing through handle_date_parse_input.
def _string_parse!(klass, v)
v = handle_date_parse_input(v)
super
end
end
module SkipBytesizeChecking
private
# Do not check max input bytesize
def check_allowed_bytesize(v, max)
end
end
# Class handling conversion of submitted parameters to desired types.
class Params
# Handle conversions for the given type using the given block.
# For a type named +foo+, this will create the following methods:
#
# * foo(key, default=nil)
# * foo!(key)
# * convert_foo(value) # private
# * _convert_array_foo(value) # private
#
# This method is used to define all type conversions, even the built
# in ones. It can be called in subclasses to setup subclass-specific
# types.
def self.handle_type(type, opts=OPTS, &block)
convert_meth = :"convert_#{type}"
define_method(convert_meth, &block)
max_input_bytesize = opts[:max_input_bytesize]
max_input_bytesize_meth = :"_max_input_bytesize_for_#{type}"
define_method(max_input_bytesize_meth){max_input_bytesize}
convert_array_meth = :"_convert_array_#{type}"
define_method(convert_array_meth) do |v|
raise Error, "expected array but received #{v.inspect}" unless v.is_a?(Array)
v.map! do |val|
check_allowed_bytesize(val, send(max_input_bytesize_meth))
check_null_byte(val)
send(convert_meth, val)
end
end
private convert_meth, convert_array_meth, max_input_bytesize_meth
alias_method max_input_bytesize_meth, max_input_bytesize_meth
define_method(type) do |key, default=nil|
process_arg(convert_meth, key, default, send(max_input_bytesize_meth)) if require_hash!
end
define_method(:"#{type}!") do |key|
send(type, key, CHECK_NIL)
end
end
# Override the maximum input bytesize for the given type. This is mostly useful
# for overriding the sizes for the default input types.
def self.max_input_bytesize(type, bytesize)
max_input_bytesize_meth = :"_max_input_bytesize_for_#{type}"
define_method(max_input_bytesize_meth){bytesize}
private max_input_bytesize_meth
alias_method max_input_bytesize_meth, max_input_bytesize_meth
end
# Create a new instance with the given object and nesting level.
# +obj+ should be an array or hash, and +nesting+ should be an
# array. Designed for internal use, should not be called by
# external code.
def self.nest(obj, nesting)
v = allocate
v.instance_variable_set(:@nesting, nesting)
v.send(:initialize, obj)
v
end
handle_type(:any) do |v|
v
end
handle_type(:str) do |v|
raise Error, "expected string but received #{v.inspect}" unless v.is_a?(::String)
v
end
handle_type(:nonempty_str) do |v|
if (v = convert_str(v)) && !v.strip.empty?
v
end
end
handle_type(:bool) do |v|
case v
when ''
nil
when false, 0, /\A(?:0|f(?:alse)?|no?|off)\z/i
false
when true, 1, /\A(?:1|t(?:rue)?|y(?:es)?|on)\z/i
true
else
raise Error, "expected bool but received #{v.inspect}"
end
end
handle_type(:int, :max_input_bytesize=>100) do |v|
string_or_numeric!(v) && v.to_i
end
alias base_convert_int convert_int
handle_type(:pos_int, :max_input_bytesize=>100) do |v|
if (v = base_convert_int(v)) && v > 0
v
end
end
handle_type(:Integer, :max_input_bytesize=>100) do |v|
if string_or_numeric!(v)
case v
when String
::Kernel::Integer(v, 10)
when Integer
v
else
i = ::Kernel::Integer(v)
raise Error, "numeric value passed to Integer contains non-Integer part: #{v.inspect}" unless i == v
i
end
end
end
alias base_convert_Integer convert_Integer
handle_type(:float, :max_input_bytesize=>1000) do |v|
string_or_numeric!(v) && v.to_f
end
handle_type(:Float, :max_input_bytesize=>1000) do |v|
string_or_numeric!(v) && ::Kernel::Float(v)
end
handle_type(:Hash) do |v|
raise Error, "expected hash but received #{v.inspect}" unless v.is_a?(::Hash)
v
end
handle_type(:date, :max_input_bytesize=>128) do |v|
parse!(::Date, v)
end
handle_type(:time, :max_input_bytesize=>128) do |v|
parse!(::Time, v)
end
handle_type(:datetime, :max_input_bytesize=>128) do |v|
parse!(::DateTime, v)
end
handle_type(:file) do |v|
raise Error, "expected hash with :tempfile entry" unless v.is_a?(::Hash) && v.has_key?(:tempfile) && v[:tempfile].respond_to?(:read)
v
end
# Set the object used for converting. Conversion methods will convert members of
# the passed object.
def initialize(obj)
case @obj = obj
when Hash, Array
# nothing
else
if @nesting
handle_error(nil, (@obj.nil? ? :missing : :invalid_type), "value of #{param_name(nil)} parameter not an array or hash: #{obj.inspect}", true)
else
handle_error(nil, :invalid_type, "parameters given not an array or hash: #{obj.inspect}", true)
end
end
end
# If key is a String Return whether the key is present in the object,
def present?(key)
case key
when String
!any(key).nil?
when Array
key.all? do |k|
raise ProgrammerError, "non-String element in array argument passed to present?: #{k.inspect}" unless k.is_a?(String)
!any(k).nil?
end
else
raise ProgrammerError, "unexpected argument passed to present?: #{key.inspect}"
end
end
# Return a new Params instance for the given +key+. The value of +key+ should be an array
# if +key+ is an integer, or hash otherwise.
def [](key)
@subs ||= {}
if sub = @subs[key]
return sub
end
if @obj.is_a?(Array)
unless key.is_a?(Integer)
handle_error(key, :invalid_type, "invalid use of non-integer key for accessing array: #{key.inspect}", true)
end
else
if key.is_a?(Integer)
handle_error(key, :invalid_type, "invalid use of integer key for accessing hash: #{key}", true)
end
end
v = @obj[key]
v = yield if v.nil? && defined?(yield)
begin
sub = self.class.nest(v, Array(@nesting) + [key])
rescue => e
handle_error(key, :invalid_type, e, true)
end
@subs[key] = sub
sub.sub_capture(@capture, @symbolize, @skip_missing)
sub
end
# Return the nested value for key. If there is no nested_value for +key+,
# calls the block to return the value, or returns nil if there is no block given.
def fetch(key)
send(:[], key){return(yield if defined?(yield))}
end
# Captures conversions inside the given block, and returns a hash of all conversions,
# including conversions of subkeys. +keys+ should be an array of subkeys to access,
# or nil to convert the current object. If +keys+ is given as a hash, it is used as
# the options hash. Options:
#
# :raise :: If set to false, do not raise errors for missing keys
# :skip_missing :: If set to true, does not store values if the key is not
# present in the params.
# :symbolize :: Convert any string keys in the resulting hash and for any
# conversions below
def convert!(keys=nil, opts=OPTS)
if keys.is_a?(Hash)
opts = keys
keys = nil
end
_capture!(:nested_params, opts) do
if sub = subkey(Array(keys).dup, opts.fetch(:raise, true))
yield sub
end
end
end
# Runs conversions similar to convert! for each key specified by the :keys option. If :keys option is not given
# and the object is an array, runs conversions for all entries in the array. If the :keys
# option is not given and the object is a Hash with string keys '0', '1', ..., 'N' (with
# no skipped keys), runs conversions for all entries in the hash. If :keys option is a Proc
# or a Method, calls the proc/method with the current object, which should return an
# array of keys to use.
# Supports options given to #convert!, and this additional option:
#
# :keys :: The keys to extract from the object. If a proc or method,
# calls the value with the current object, which should return the array of keys
# to use.
def convert_each!(opts=OPTS, &block)
np = !@capture
_capture!(nil, opts) do
case keys = opts[:keys]
when nil
keys = (0...@obj.length)
valid = if @obj.is_a?(Array)
true
else
keys = keys.map(&:to_s)
keys.all?{|k| @obj.has_key?(k)}
end
unless valid
handle_error(nil, :invalid_type, "convert_each! called on object not an array or hash with keys '0'..'N'")
next
end
when Array
# nothing to do
when Proc, Method
keys = keys.call(@obj)
else
raise ProgrammerError, "unsupported convert_each! :keys option: #{keys.inspect}"
end
keys.map do |i|
begin
if v = subkey([i], opts.fetch(:raise, true))
yield v
v.nested_params if np
end
rescue => e
handle_error(i, :invalid_type, e)
end
end
end
end
# Convert values nested under the current obj. Traverses the current object using +nest+, then converts
# +key+ on that object using +type+:
#
# tp.dig(:pos_int, 'foo') # tp.pos_int('foo')
# tp.dig(:pos_int, 'foo', 'bar') # tp['foo'].pos_int('bar')
# tp.dig(:pos_int, 'foo', 'bar', 'baz') # tp['foo']['bar'].pos_int('baz')
#
# Returns nil if any of the values are not present or not the expected type. If the nest path results
# in an object that is not an array or hash, then raises an Error.
#
# You can use +dig+ to get access to nested arrays by using <tt>:array</tt> or <tt>:array!</tt> as
# the first argument and providing the type in the second argument:
#
# tp.dig(:array, :pos_int, 'foo', 'bar', 'baz') # tp['foo']['bar'].array(:int, 'baz')
def dig(type, *nest, key)
_dig(false, type, nest, key)
end
# Similar to +dig+, but raises an Error instead of returning +nil+ if no value is found.
def dig!(type, *nest, key)
_dig(true, type, nest, key)
end
# Convert the value of +key+ to an array of values of the given +type+. If +default+ is
# given, any +nil+ values in the array are replaced with +default+. If +key+ is an array
# then this returns an array of arrays, one for each respective value of +key+. If there is
# no value for +key+, nil is returned instead of an array.
def array(type, key, default=nil)
meth = :"_convert_array_#{type}"
raise ProgrammerError, "no typecast_params type registered for #{type.inspect}" unless respond_to?(meth, true)
process_arg(meth, key, default, send(:"_max_input_bytesize_for_#{type}")) if require_hash!
end
# Call +array+ with the +type+, +key+, and +default+, but if the return value is nil or any value in
# the returned array is +nil+, raise an Error.
def array!(type, key, default=nil)
v = array(type, key, default)
if key.is_a?(Array)
key.zip(v).each do |k, arr|
check_array!(k, arr)
end
else
check_array!(key, v)
end
v
end
protected
# Recursively descendent into all known subkeys and get the converted params from each.
def nested_params
return @nested_params if @nested_params
params = @params
if @subs
@subs.each do |key, v|
if key.is_a?(String) && symbolize?
key = key.to_sym
end
params[key] = v.nested_params
end
end
params
end
# Recursive method to get subkeys.
def subkey(keys, do_raise)
unless key = keys.shift
return self
end
reason = :invalid_type
case key
when String
unless @obj.is_a?(Hash)
raise Error, "parameter #{param_name(nil)} is not a hash" if do_raise
return
end
present = !@obj[key].nil?
when Integer
unless @obj.is_a?(Array)
raise Error, "parameter #{param_name(nil)} is not an array" if do_raise
return
end
present = key < @obj.length
else
raise ProgrammerError, "invalid argument used to traverse parameters: #{key.inspect}"
end
unless present
reason = :missing
raise Error, "parameter #{param_name(key)} is not present" if do_raise
return
end
self[key].subkey(keys, do_raise)
rescue => e
handle_error(key, reason, e)
end
# Inherit given capturing and symbolize setting from parent object.
def sub_capture(capture, symbolize, skip_missing)
if @capture = capture
@symbolize = symbolize
@skip_missing = skip_missing
@params = @obj.class.new
end
end
private
# Whether to symbolize keys when capturing. Note that the method
# is renamed to +symbolize?+.
attr_reader :symbolize
alias symbolize? symbolize
undef symbolize
# Internals of convert! and convert_each!.
def _capture!(ret, opts)
previous_symbolize = @symbolize
previous_skip_missing = @skip_missing
unless cap = @capture
@params = @obj.class.new
@subs.clear if @subs
capturing_started = true
cap = @capture = []
end
if opts.has_key?(:symbolize)
@symbolize = !!opts[:symbolize]
end
if opts.has_key?(:skip_missing)
@skip_missing = !!opts[:skip_missing]
end
begin
v = yield
rescue Error => e
cap << e unless cap.last == e
end
if capturing_started
unless cap.empty?
e = cap[0]
e.all_errors = cap
raise e
end
if ret == :nested_params
nested_params
else
v
end
end
ensure
@nested_params = nil
if capturing_started
# Unset capturing if capturing was already started.
@capture = nil
else
# If capturing was not already started, update cached nested params
# before resetting symbolize setting.
@nested_params = nested_params
end
@symbolize = previous_symbolize
@skip_missing = previous_skip_missing
end
# Raise an error if the array given does contains nil values.
def check_array!(key, arr)
if arr
if arr.any?{|val| val.nil?}
handle_error(key, :invalid_type, "invalid value in array parameter #{param_name(key)}")
end
else
handle_error(key, :missing, "missing parameter for #{param_name(key)}")
end
end
# Internals of dig/dig!
def _dig(force, type, nest, key)
if type == :array || type == :array!
conv_type = nest.shift
unless conv_type.is_a?(Symbol)
raise ProgrammerError, "incorrect subtype given when using #{type} as argument for dig/dig!: #{conv_type.inspect}"
end
meth = type
type = conv_type
args = [meth, type]
else
meth = type
args = [type]
end
unless respond_to?("_convert_array_#{type}", true)
raise ProgrammerError, "no typecast_params type registered for #{meth.inspect}"
end
if v = subkey(nest, force)
v.send(*args, key, (CHECK_NIL if force))
end
end
# Format a reasonable parameter name value, for use in exception messages.
def param_name(key)
first, *rest = keys(key)
if first
v = first.dup
rest.each do |param|
v << "[#{param}]"
end
v
end
end
# If +key+ is not +nil+, add it to the given nesting. Otherwise, just return the given nesting.
# Designed for use in setting the +keys+ values in raised exceptions.
def keys(key)
Array(@nesting) + Array(key)
end
# Handle any conversion errors. By default, reraises Error instances with the keys set,
# converts ::ArgumentError instances to Error instances, and reraises other exceptions.
def handle_error(key, reason, e, do_raise=false)
case e
when String
handle_error(key, reason, Error.new(e), do_raise)
when Error, ArgumentError
if @capture && (le = @capture.last) && le == e
raise e if do_raise
return
end
e = Error.create(keys(key), reason, e)
if @capture
@capture << e
raise e if do_raise
nil
else
raise e
end
else
raise e
end
end
# Issue an error unless the current object is a hash. Used to ensure we don't try to access
# entries if the current object is an array.
def require_hash!
@obj.is_a?(Hash) || handle_error(nil, :invalid_type, "expected hash object in #{param_name(nil)} but received array object")
end
# If +key+ is not an array, convert the value at the given +key+ using the +meth+ method and +default+
# value. If +key+ is an array, return an array with the conversion done for each respective member of +key+.
def process_arg(meth, key, default, max_input_bytesize=nil)
case key
when String
v = process(meth, key, default, max_input_bytesize)
if @capture
key = key.to_sym if symbolize?
if !@skip_missing || @obj.has_key?(key)
@params[key] = v
end
end
v
when Array
key.map do |k|
raise ProgrammerError, "non-String element in array argument passed to typecast_params: #{k.inspect}" unless k.is_a?(String)
process_arg(meth, k, default, max_input_bytesize)
end
else
raise ProgrammerError, "Unsupported argument for typecast_params conversion method: #{key.inspect}"
end
end
# Raise an Error if the value is a string with bytesize over max (if max is given)
def check_allowed_bytesize(v, max)
if max && v.is_a?(String) && v.bytesize > max
handle_error(nil, :too_long, "string parameter is too long for type", true)
end
end
# Raise an Error if the value is a string containing a null byte.
def check_null_byte(v)
if v.is_a?(String) && v.index("\0")
handle_error(nil, :null_byte, "string parameter contains null byte", true)
end
end
# Get the value of +key+ for the object, and convert it to the expected type using +meth+.
# If the value either before or after conversion is nil, return the +default+ value.
def process(meth, key, default, max_input_bytesize=nil)
v = param_value(key)
unless v.nil?
check_allowed_bytesize(v, max_input_bytesize)
check_null_byte(v)
v = send(meth, v)
end
if v.nil?
if default == CHECK_NIL
handle_error(key, :missing, "missing parameter for #{param_name(key)}")
end
default
else
v
end
rescue => e
handle_error(key, meth.to_s.sub(/\A_?convert_/, '').to_sym, e)
end
# Get the value for the given key in the object.
def param_value(key)
@obj[key]
end
# Helper for conversion methods where '' should be considered nil,
# and only String or Numeric values should be converted.
def string_or_numeric!(v)
case v
when ''
nil
when String, Numeric
true
else
raise Error, "unexpected value received: #{v.inspect}"
end
end
# Helper for conversion methods where '' should be considered nil,
# and only String values should be converted by calling +parse+ on
# the given +klass+.
def parse!(klass, v)
case v
when ''
nil
when String
_string_parse!(klass, v)
else
raise Error, "unexpected value received: #{v.inspect}"
end
end
# Handle parsing for string values passed to parse!.
def _string_parse!(klass, v)
klass.parse(v)
end
end
# Set application-specific Params subclass unless one has been set,
# and if a block is passed, eval it in the context of the subclass.
# Respect the <tt>strip: :all</tt> to strip all parameter strings
# before processing them.
def self.configure(app, opts=OPTS, &block)
app.const_set(:TypecastParams, Class.new(RodaPlugins::TypecastParams::Params)) unless app.const_defined?(:TypecastParams)
app::TypecastParams.class_eval(&block) if block
if opts[:strip] == :all
app::TypecastParams.send(:include, StringStripper)
end
if opts[:allow_null_bytes]
app::TypecastParams.send(:include, AllowNullByte)
end
if opts[:skip_bytesize_checking]
app::TypecastParams.send(:include, SkipBytesizeChecking)
end
if opts[:date_parse_input_handler]
app::TypecastParams.class_eval do
include DateParseInputHandler
define_method(:handle_date_parse_input, &opts[:date_parse_input_handler])
private :handle_date_parse_input
alias handle_date_parse_input handle_date_parse_input
end
end
end
module ClassMethods
# Freeze the Params subclass when freezing the class.
def freeze
self::TypecastParams.freeze
super
end
# Assign the application subclass a subclass of the current Params subclass.
def inherited(subclass)
super
subclass.const_set(:TypecastParams, Class.new(self::TypecastParams))
end
end
module InstanceMethods
# Return and cache the instance of the Params class for the current request.
# Type conversion methods will be called on the result of this method.
def typecast_params
@_typecast_params ||= self.class::TypecastParams.new(@_request.params)
end
end
end
register_plugin(:typecast_params, TypecastParams)
end
end