Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow validation of nested parameters through block #60

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 49 additions & 14 deletions lib/sinatra/param.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions spec/dummy/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
151 changes: 151 additions & 0 deletions spec/parameter_nested_validations_spec.rb
Original file line number Diff line number Diff line change
@@ -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