Skip to content

Commit bb9db2d

Browse files
author
Cody Wright
committed
allow validation of nested parameters through block
1 parent b856928 commit bb9db2d

File tree

4 files changed

+268
-14
lines changed

4 files changed

+268
-14
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ param :y, String
118118
any_of :x, :y
119119
```
120120

121+
## Nested Hash Validation
122+
123+
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.
124+
This block will only be run if the top level validation passes and the key is present.
125+
126+
```ruby
127+
param :a, Hash do
128+
param :b, String
129+
param :c, Hash do
130+
param :d, Integer
131+
end
132+
end
133+
```
134+
121135
### Exceptions
122136

123137
By default, when a parameter precondition fails, `Sinatra::Param` will `halt 400` with an error message:

lib/sinatra/param.rb

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,44 @@ class InvalidParameterError < StandardError
1313

1414
def param(name, type, options = {})
1515
name = name.to_s
16+
applicable_params = @applicable_params || params
1617

17-
return unless params.member?(name) or options[:default] or options[:required]
18+
return unless applicable_params.member?(name) or options[:default] or options[:required]
1819

1920
begin
20-
params[name] = coerce(params[name], type, options)
21-
params[name] = (options[:default].call if options[:default].respond_to?(:call)) || options[:default] if params[name].nil? and options[:default]
22-
params[name] = options[:transform].to_proc.call(params[name]) if params[name] and options[:transform]
23-
validate!(params[name], options)
21+
applicable_params[name] = coerce(applicable_params[name], type, options)
22+
applicable_params[name] = (options[:default].call if options[:default].respond_to?(:call)) || options[:default] if applicable_params[name].nil? and options[:default]
23+
applicable_params[name] = options[:transform].to_proc.call(applicable_params[name]) if applicable_params[name] and options[:transform]
24+
validate!(applicable_params[name], options)
25+
26+
27+
if block_given?
28+
if type != Hash
29+
raise Sinatra::Param::InvalidParameterError.new(
30+
'Only the Hash parameter validation can use sub hash validation method')
31+
end
32+
original_applicable_params = @applicable_params
33+
original_parent_key_name = @parent_key_name
34+
@applicable_params = applicable_params[name]
35+
@parent_key_name = formatted_params(@parent_key_name, name)
36+
37+
yield
38+
39+
@applicable_params = original_applicable_params
40+
@parent_key_name = original_parent_key_name
41+
end
2442
rescue InvalidParameterError => exception
43+
exception_name = formatted_params(@parent_key_name, name)
2544
if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false)
26-
exception.param, exception.options = name, options
45+
exception.param = exception_name
46+
exception.options = options
2747
raise exception
2848
end
2949

3050
error = exception.to_s
3151

3252
if content_type and content_type.match(mime_type(:json))
33-
error = {message: error, errors: {name => exception.message}}.to_json
53+
error = {message: error, errors: {exception_name => exception.message}}.to_json
3454
end
3555

3656
halt 400, error
@@ -40,18 +60,19 @@ def param(name, type, options = {})
4060
def one_of(*args)
4161
options = args.last.is_a?(Hash) ? args.pop : {}
4262
names = args.collect(&:to_s)
63+
applicable_params = @applicable_params || params
4364

4465
return unless names.length >= 2
4566

4667
begin
47-
validate_one_of!(params, names, options)
68+
validate_one_of!(applicable_params, names, options)
4869
rescue InvalidParameterError => exception
4970
if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false)
5071
exception.param, exception.options = names, options
5172
raise exception
5273
end
5374

54-
error = "Invalid parameters [#{names.join(', ')}]"
75+
error = "Invalid parameters #{formatted_params(@parent_key_name, names)}"
5576
if content_type and content_type.match(mime_type(:json))
5677
error = {message: error, errors: {names => exception.message}}.to_json
5778
end
@@ -63,20 +84,22 @@ def one_of(*args)
6384
def any_of(*args)
6485
options = args.last.is_a?(Hash) ? args.pop : {}
6586
names = args.collect(&:to_s)
87+
applicable_params = @applicable_params || params
6688

6789
return unless names.length >= 2
6890

6991
begin
70-
validate_any_of!(params, names, options)
92+
validate_any_of!(applicable_params, names, options)
7193
rescue InvalidParameterError => exception
7294
if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false)
7395
exception.param, exception.options = names, options
7496
raise exception
7597
end
7698

77-
error = "Invalid parameters [#{names.join(', ')}]"
99+
formatted_params = formatted_params(@parent_key_name, names)
100+
error = "Invalid parameters #{formatted_params}"
78101
if content_type and content_type.match(mime_type(:json))
79-
error = {message: error, errors: {names => exception.message}}.to_json
102+
error = {message: error, errors: {formatted_params => exception.message}}.to_json
80103
end
81104

82105
halt 400, error
@@ -143,11 +166,15 @@ def validate!(param, options)
143166
end
144167

145168
def validate_one_of!(params, names, options)
146-
raise InvalidParameterError, "Only one of [#{names.join(', ')}] is allowed" if names.count{|name| present?(params[name])} > 1
169+
if names.count{|name| present?(params[name])} > 1
170+
raise InvalidParameterError, "Only one of #{formatted_params(@parent_key_name, names)} is allowed"
171+
end
147172
end
148173

149174
def validate_any_of!(params, names, options)
150-
raise InvalidParameterError, "One of parameters [#{names.join(', ')}] is required" if names.count{|name| present?(params[name])} < 1
175+
if names.count{|name| present?(params[name])} < 1
176+
raise InvalidParameterError, "One of parameters #{formatted_params(@parent_key_name, names)} is required"
177+
end
151178
end
152179

153180
# ActiveSupport #present? and #blank? without patching Object
@@ -158,6 +185,14 @@ def present?(object)
158185
def blank?(object)
159186
object.respond_to?(:empty?) ? object.empty? : !object
160187
end
188+
189+
def formatted_params(parent_key, name)
190+
if name.is_a?(Array)
191+
name = "[#{name.join(', ')}]"
192+
end
193+
194+
return parent_key ? "#{parent_key}[#{name}]" : name
195+
end
161196
end
162197

163198
helpers Param

spec/dummy/app.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,58 @@ class App < Sinatra::Base
261261
message: 'OK'
262262
}.to_json
263263
end
264+
265+
get '/validation/hash/nested_values' do
266+
param :parent, Hash do
267+
param :required_child, Integer, :required => true
268+
param :optional_child, String
269+
param :nested_child, Hash do
270+
param :required_sub_child, String, :required => true
271+
param :optional_sub_child, Integer
272+
end
273+
param :default_child, Boolean, :default => true
274+
end
275+
276+
{
277+
message: 'OK'
278+
}.to_json
279+
end
280+
281+
get '/validation/hash/bad_nested_values' do
282+
param :parent, String do
283+
param :child, String
284+
end
285+
286+
{
287+
message: 'OK'
288+
}.to_json
289+
end
290+
291+
get '/one_of/nested' do
292+
param :parent, Hash do
293+
param :a, String
294+
param :b, String
295+
param :c, String
296+
297+
one_of :a, :b, :c
298+
end
299+
300+
{
301+
message: 'OK'
302+
}.to_json
303+
end
304+
305+
get '/any_of/nested' do
306+
param :parent, Hash do
307+
param :a, String
308+
param :b, String
309+
param :c, String
310+
311+
any_of :a, :b, :c
312+
end
313+
314+
{
315+
message: 'OK'
316+
}.to_json
317+
end
264318
end
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
require 'spec_helper'
2+
3+
describe 'Nested Validation' do
4+
context '' do
5+
it 'should validate the children when the parent is present' do
6+
params = {
7+
:parent => {
8+
:required_child => 1,
9+
}
10+
}
11+
12+
get("/validation/hash/nested_values", params) do |response|
13+
expect(response.status).to eq(200)
14+
expect(JSON.parse(response.body)['message']).to eq("OK")
15+
end
16+
end
17+
18+
it 'should be invalid when the parent is present but a nested validation fails' do
19+
params = {
20+
:parent => {
21+
:optional_chlid => 'test'
22+
}
23+
}
24+
25+
get("/validation/hash/nested_values", params) do |response|
26+
expect(response.status).to eq(400)
27+
body = JSON.parse(response.body)
28+
expect(body['message']).to eq("Parameter is required")
29+
expect(body['errors']).to eq({
30+
"parent[required_child]" => "Parameter is required"
31+
})
32+
end
33+
end
34+
35+
it 'should not require sub params when the parent hash is not present and not required' do
36+
params = {}
37+
get("/validation/hash/nested_values", params) do |response|
38+
expect(response.status).to eq(200)
39+
expect(JSON.parse(response.body)['message']).to eq("OK")
40+
end
41+
end
42+
43+
it 'should allow arbitrary levels of nesting' do
44+
params = {
45+
:parent => {
46+
:required_child => 1,
47+
:nested_child => {
48+
:required_sub_child => 'test'
49+
}
50+
}
51+
}
52+
53+
get("/validation/hash/nested_values", params) do |response|
54+
expect(response.status).to eq(200)
55+
expect(JSON.parse(response.body)['message']).to eq("OK")
56+
end
57+
end
58+
59+
it 'should have the proper error message for multiple levels deep validation errors' do
60+
params = {
61+
:parent => {
62+
:required_child => 1,
63+
:nested_child => {
64+
:required_sub_child => 'test',
65+
:optional_sub_child => 'test'
66+
}
67+
}
68+
}
69+
70+
get("/validation/hash/nested_values", params) do |response|
71+
expect(response.status).to eq(400)
72+
body = JSON.parse(response.body)
73+
expect(body['message']).to eq("'test' is not a valid Integer")
74+
expect(body['errors']).to eq({
75+
"parent[nested_child][optional_sub_child]" => "'test' is not a valid Integer"
76+
})
77+
end
78+
end
79+
80+
it 'should error when sub hash validation is tried on a non Hash parameter' do
81+
params = {
82+
:parent => {
83+
:child => 'test'
84+
}
85+
}
86+
87+
get("/validation/hash/bad_nested_values", params) do |response|
88+
expect(response.status).to eq(400)
89+
body = JSON.parse(response.body)
90+
expect(body['message']).to eq("Only the Hash parameter validation can use sub hash validation method")
91+
expect(body['errors']).to eq({
92+
"parent" => "Only the Hash parameter validation can use sub hash validation method"
93+
})
94+
end
95+
end
96+
97+
it 'should work with one_of nested in a hash' do
98+
params = {
99+
:parent => {
100+
:a => 'test'
101+
}
102+
}
103+
104+
get("/one_of/nested", params) do |response|
105+
expect(response.status).to eq(200)
106+
expect(JSON.parse(response.body)['message']).to eq("OK")
107+
end
108+
end
109+
110+
it "should error when one_of isn't satisfied in a nested hash" do
111+
params = {
112+
:parent => {
113+
:a => 'test',
114+
:b => 'test'
115+
}
116+
}
117+
118+
get("/one_of/nested", params) do |response|
119+
expect(response.status).to eq(400)
120+
expect(JSON.parse(response.body)['message']).to eq("Invalid parameters parent[[a, b, c]]")
121+
end
122+
end
123+
124+
it 'should work with any_of nested in a hash' do
125+
params = {
126+
:parent => {
127+
:a => 'test'
128+
}
129+
}
130+
131+
get("/any_of/nested", params) do |response|
132+
expect(response.status).to eq(200)
133+
expect(JSON.parse(response.body)['message']).to eq("OK")
134+
end
135+
end
136+
137+
it "should error when one_of isn't satisfied in a nested hash" do
138+
params = {
139+
:parent => {
140+
:d => 'test'
141+
}
142+
}
143+
144+
get("/any_of/nested", params) do |response|
145+
expect(response.status).to eq(400)
146+
expect(JSON.parse(response.body)['message']).to eq("Invalid parameters parent[[a, b, c]]")
147+
end
148+
end
149+
150+
end
151+
end

0 commit comments

Comments
 (0)