From bb9db2d5db6780c6292c75ce3964cb2f4a2a7916 Mon Sep 17 00:00:00 2001 From: Cody Wright Date: Fri, 30 Oct 2015 15:51:35 -0400 Subject: [PATCH] allow validation of nested parameters through block --- README.md | 14 ++ lib/sinatra/param.rb | 63 +++++++-- spec/dummy/app.rb | 54 ++++++++ spec/parameter_nested_validations_spec.rb | 151 ++++++++++++++++++++++ 4 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 spec/parameter_nested_validations_spec.rb diff --git a/README.md b/README.md index d5eaf51..9c765b2 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,20 @@ param :y, String any_of :x, :y ``` +## Nested Hash Validation + +Using block syntax, a route can validate the fields nested in a parameter of Hash type. These hashes can be nested to an arbitrary depth. +This block will only be run if the top level validation passes and the key is present. + +```ruby +param :a, Hash do + param :b, String + param :c, Hash do + param :d, Integer + end +end +``` + ### Exceptions By default, when a parameter precondition fails, `Sinatra::Param` will `halt 400` with an error message: diff --git a/lib/sinatra/param.rb b/lib/sinatra/param.rb index 7762b84..398aa15 100644 --- a/lib/sinatra/param.rb +++ b/lib/sinatra/param.rb @@ -13,24 +13,44 @@ class InvalidParameterError < StandardError def param(name, type, options = {}) name = name.to_s + applicable_params = @applicable_params || params - return unless params.member?(name) or options[:default] or options[:required] + return unless applicable_params.member?(name) or options[:default] or options[:required] begin - params[name] = coerce(params[name], type, options) - params[name] = (options[:default].call if options[:default].respond_to?(:call)) || options[:default] if params[name].nil? and options[:default] - params[name] = options[:transform].to_proc.call(params[name]) if params[name] and options[:transform] - validate!(params[name], options) + applicable_params[name] = coerce(applicable_params[name], type, options) + applicable_params[name] = (options[:default].call if options[:default].respond_to?(:call)) || options[:default] if applicable_params[name].nil? and options[:default] + applicable_params[name] = options[:transform].to_proc.call(applicable_params[name]) if applicable_params[name] and options[:transform] + validate!(applicable_params[name], options) + + + if block_given? + if type != Hash + raise Sinatra::Param::InvalidParameterError.new( + 'Only the Hash parameter validation can use sub hash validation method') + end + original_applicable_params = @applicable_params + original_parent_key_name = @parent_key_name + @applicable_params = applicable_params[name] + @parent_key_name = formatted_params(@parent_key_name, name) + + yield + + @applicable_params = original_applicable_params + @parent_key_name = original_parent_key_name + end rescue InvalidParameterError => exception + exception_name = formatted_params(@parent_key_name, name) if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false) - exception.param, exception.options = name, options + exception.param = exception_name + exception.options = options raise exception end error = exception.to_s if content_type and content_type.match(mime_type(:json)) - error = {message: error, errors: {name => exception.message}}.to_json + error = {message: error, errors: {exception_name => exception.message}}.to_json end halt 400, error @@ -40,18 +60,19 @@ def param(name, type, options = {}) def one_of(*args) options = args.last.is_a?(Hash) ? args.pop : {} names = args.collect(&:to_s) + applicable_params = @applicable_params || params return unless names.length >= 2 begin - validate_one_of!(params, names, options) + validate_one_of!(applicable_params, names, options) rescue InvalidParameterError => exception if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false) exception.param, exception.options = names, options raise exception end - error = "Invalid parameters [#{names.join(', ')}]" + error = "Invalid parameters #{formatted_params(@parent_key_name, names)}" if content_type and content_type.match(mime_type(:json)) error = {message: error, errors: {names => exception.message}}.to_json end @@ -63,20 +84,22 @@ def one_of(*args) def any_of(*args) options = args.last.is_a?(Hash) ? args.pop : {} names = args.collect(&:to_s) + applicable_params = @applicable_params || params return unless names.length >= 2 begin - validate_any_of!(params, names, options) + validate_any_of!(applicable_params, names, options) rescue InvalidParameterError => exception if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false) exception.param, exception.options = names, options raise exception end - error = "Invalid parameters [#{names.join(', ')}]" + formatted_params = formatted_params(@parent_key_name, names) + error = "Invalid parameters #{formatted_params}" if content_type and content_type.match(mime_type(:json)) - error = {message: error, errors: {names => exception.message}}.to_json + error = {message: error, errors: {formatted_params => exception.message}}.to_json end halt 400, error @@ -143,11 +166,15 @@ def validate!(param, options) end def validate_one_of!(params, names, options) - raise InvalidParameterError, "Only one of [#{names.join(', ')}] is allowed" if names.count{|name| present?(params[name])} > 1 + if names.count{|name| present?(params[name])} > 1 + raise InvalidParameterError, "Only one of #{formatted_params(@parent_key_name, names)} is allowed" + end end def validate_any_of!(params, names, options) - raise InvalidParameterError, "One of parameters [#{names.join(', ')}] is required" if names.count{|name| present?(params[name])} < 1 + if names.count{|name| present?(params[name])} < 1 + raise InvalidParameterError, "One of parameters #{formatted_params(@parent_key_name, names)} is required" + end end # ActiveSupport #present? and #blank? without patching Object @@ -158,6 +185,14 @@ def present?(object) def blank?(object) object.respond_to?(:empty?) ? object.empty? : !object end + + def formatted_params(parent_key, name) + if name.is_a?(Array) + name = "[#{name.join(', ')}]" + end + + return parent_key ? "#{parent_key}[#{name}]" : name + end end helpers Param diff --git a/spec/dummy/app.rb b/spec/dummy/app.rb index f577123..5d29bab 100644 --- a/spec/dummy/app.rb +++ b/spec/dummy/app.rb @@ -261,4 +261,58 @@ class App < Sinatra::Base message: 'OK' }.to_json end + + get '/validation/hash/nested_values' do + param :parent, Hash do + param :required_child, Integer, :required => true + param :optional_child, String + param :nested_child, Hash do + param :required_sub_child, String, :required => true + param :optional_sub_child, Integer + end + param :default_child, Boolean, :default => true + end + + { + message: 'OK' + }.to_json + end + + get '/validation/hash/bad_nested_values' do + param :parent, String do + param :child, String + end + + { + message: 'OK' + }.to_json + end + + get '/one_of/nested' do + param :parent, Hash do + param :a, String + param :b, String + param :c, String + + one_of :a, :b, :c + end + + { + message: 'OK' + }.to_json + end + + get '/any_of/nested' do + param :parent, Hash do + param :a, String + param :b, String + param :c, String + + any_of :a, :b, :c + end + + { + message: 'OK' + }.to_json + end end diff --git a/spec/parameter_nested_validations_spec.rb b/spec/parameter_nested_validations_spec.rb new file mode 100644 index 0000000..7c26983 --- /dev/null +++ b/spec/parameter_nested_validations_spec.rb @@ -0,0 +1,151 @@ +require 'spec_helper' + +describe 'Nested Validation' do + context '' do + it 'should validate the children when the parent is present' do + params = { + :parent => { + :required_child => 1, + } + } + + get("/validation/hash/nested_values", params) do |response| + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['message']).to eq("OK") + end + end + + it 'should be invalid when the parent is present but a nested validation fails' do + params = { + :parent => { + :optional_chlid => 'test' + } + } + + get("/validation/hash/nested_values", params) do |response| + expect(response.status).to eq(400) + body = JSON.parse(response.body) + expect(body['message']).to eq("Parameter is required") + expect(body['errors']).to eq({ + "parent[required_child]" => "Parameter is required" + }) + end + end + + it 'should not require sub params when the parent hash is not present and not required' do + params = {} + get("/validation/hash/nested_values", params) do |response| + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['message']).to eq("OK") + end + end + + it 'should allow arbitrary levels of nesting' do + params = { + :parent => { + :required_child => 1, + :nested_child => { + :required_sub_child => 'test' + } + } + } + + get("/validation/hash/nested_values", params) do |response| + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['message']).to eq("OK") + end + end + + it 'should have the proper error message for multiple levels deep validation errors' do + params = { + :parent => { + :required_child => 1, + :nested_child => { + :required_sub_child => 'test', + :optional_sub_child => 'test' + } + } + } + + get("/validation/hash/nested_values", params) do |response| + expect(response.status).to eq(400) + body = JSON.parse(response.body) + expect(body['message']).to eq("'test' is not a valid Integer") + expect(body['errors']).to eq({ + "parent[nested_child][optional_sub_child]" => "'test' is not a valid Integer" + }) + end + end + + it 'should error when sub hash validation is tried on a non Hash parameter' do + params = { + :parent => { + :child => 'test' + } + } + + get("/validation/hash/bad_nested_values", params) do |response| + expect(response.status).to eq(400) + body = JSON.parse(response.body) + expect(body['message']).to eq("Only the Hash parameter validation can use sub hash validation method") + expect(body['errors']).to eq({ + "parent" => "Only the Hash parameter validation can use sub hash validation method" + }) + end + end + + it 'should work with one_of nested in a hash' do + params = { + :parent => { + :a => 'test' + } + } + + get("/one_of/nested", params) do |response| + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['message']).to eq("OK") + end + end + + it "should error when one_of isn't satisfied in a nested hash" do + params = { + :parent => { + :a => 'test', + :b => 'test' + } + } + + get("/one_of/nested", params) do |response| + expect(response.status).to eq(400) + expect(JSON.parse(response.body)['message']).to eq("Invalid parameters parent[[a, b, c]]") + end + end + + it 'should work with any_of nested in a hash' do + params = { + :parent => { + :a => 'test' + } + } + + get("/any_of/nested", params) do |response| + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['message']).to eq("OK") + end + end + + it "should error when one_of isn't satisfied in a nested hash" do + params = { + :parent => { + :d => 'test' + } + } + + get("/any_of/nested", params) do |response| + expect(response.status).to eq(400) + expect(JSON.parse(response.body)['message']).to eq("Invalid parameters parent[[a, b, c]]") + end + end + + end +end