require 'sinatra/base' module Sinatra module ParamChecker Boolean = Class.new UUID = Class.new class InvalidParameterError < StandardError attr_accessor :param, :options end class ParamScope attr_reader :params def initialize @params = [] end def required(name, options = {}) param = [:required, name.to_s, options] validate_opts! param @params << param end def optional(name, options = {}) param = [:optional, name.to_s, options] validate_opts! param @params << param end def validate!(params) validated_params = @params.reduce({}) do |memo, e| type, name, opts = e begin # handle default v = if params[name].nil? case type when :optional (opts[:default].call if opts[:default].respond_to?(:call)) || opts[:default] when :required raise InvalidParameterError, "param #{name} not found" end else params[name] end unless v.nil? coerced_value = coerce(v, opts[:type], opts) validate(coerced_value, opts) memo[name] = coerced_value end memo rescue InvalidParameterError => e e.param, e.options = name, opts raise e end end validated_params.reduce(params) do |memo, pair| k, v = pair memo[k] = v memo end end private def coerce(value, type, options = {}) return value if value.is_a?(type) case type.name.split('::').last.to_sym when :Integer Integer(value) when :Float Float(value) when :String String(value) when :UUID String(value) when :File if value.is_a?(Hash) && value[:tempfile].is_a?(::Tempfile) value else raise "cannot coerce value to #{type}" end when :Date Date.parse(value) when :Time Time.parse(value) when :DateTime DateTime.parse(value) when :Array delimiter = options[:delimiter] || ',' Array(value.split(delimiter)) when :Hash delimiter = options[:delimiter] || ',' separator = options[:separator] || ':' value.split(delimiter).map do |c| c.split(separator, 2) end.to_h when :TrueClass, :FalseClass, :Boolean if /(false|f|no|n|0)$/i === value.to_s false elsif /(true|t|yes|y|1)$/i === value.to_s true else raise "cannot coerce value to Boolean" end else raise "cannot coerce value to #{type}" end rescue => _e raise InvalidParameterError, "#{type} expected" end def validate(v, opts) case opts[:type].name.split('::').last.to_sym when :String if opts[:regexp] && !(v =~ opts[:regexp]) raise InvalidParameterError, 'wrong format' end when :UUID unless v =~ /\A[a-f0-9]{32}\z/ raise InvalidParameterError, 'uuid expected' end when :Integer, :Float if opts[:type] == Integer && opts[:range] && !opts[:range].include?(v) raise InvalidParameterError, "not in range(#{opts[:range]})" end if opts[:max] && v > opts[:max] raise InvalidParameterError, "greater than #{opts[:max]}" end if opts[:min] && v < opts[:min] raise InvalidParameterError, "smaller than #{opts[:min]}" end when :File tf = v[:tempfile] rescue nil raise InvalidParameterError, 'File expected' unless Tempfile === tf end unless [Hash, Array, File].include?(opts[:type]) if opts[:values] && !opts[:values].include?(v) raise InvalidParameterError, 'invalid enumeration member' end end end def syntax_err(msg) fail SyntaxError, "ParamChecker: #{msg}" end def check_type(type, value) return value.is_a?(String) if type == UUID return [true, false].include?(value) if [TrueClass, FalseClass, Boolean].include?(type) return true if (value.is_a?(type) rescue false) false end OPTIONS = [:type, :default, :values, :min, :max, :range, :regexp, :delimiter, :separator] def validate_opts!(param) type, name, opts = param syntax_err "#{name}: missing option :type" unless opts[:type] unless opts[:type].is_a?(Class) syntax_err "#{name}: :type value should be class" end prefix = "#{name}(#{opts[:type]})" opts.each do |k, v| syntax_err "#{prefix}: unsupported option :#{k}" unless OPTIONS.include?(k) case k when :default if type == :required syntax_err "#{prefix}: :default can be used only with :optional params" end if [File, Array, Hash].include?(opts[:type]) syntax_err "#{prefix}: :default cannot be used with :type File, Array or Hash" end unless check_type(opts[:type], v) syntax_err "#{prefix}: :default must be #{opts[:type]}" end when :values if [File, Array, Hash].include?(opts[:type]) syntax_err "#{prefix}: :values cannot be used with :type File, Array or Hash" end unless Array === v && v.size > 0 syntax_err "#{prefix}: :values must be Array(size > 0)" end v.each do |val| unless check_type(opts[:type], val) syntax_err "#{prefix}: values in :values must be #{opts[:type]}" end end when :min, :max unless [Integer, Float].include?(opts[:type]) syntax_err "#{prefix}: :#{k} can be used only with :type Integer and Float" end unless check_type(opts[:type], v) syntax_err "#{prefix}: :#{k} must be #{opts[:type]}" end when :range unless opts[:type] == Integer syntax_err "#{prefix}: :range can be used only with :type Integer" end unless Range === v && Integer === v.begin syntax_err "#{prefix}: :range must be Range of Integer" end when :regexp unless opts[:type] == String syntax_err "#{prefix}: :regexp can be used only with :type String" end unless Regexp === v syntax_err "#{prefix}: :regexp must be Regexp" end when :delimiter unless [Array, Hash].include?(opts[:type]) syntax_err "#{prefix}: :delimiter can be used only with :type Array and Hash" end when :separator unless opts[:type] == Hash syntax_err "#{prefix}: :separator can be used only with :type Hash" end end end end end def params(path = nil, options = {}, &block) ps = ParamScope.new ps.instance_eval(&block) methods = options.delete(:methods) if methods.nil? methods = [:post] end before path, options do if methods.include?(self.request.request_method.downcase.to_sym) ps.validate!(self.params) end end end end register ParamChecker end