Skip to content

Commit 449762e

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

File tree

4 files changed

+236
-14
lines changed

4 files changed

+236
-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: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,39 @@ 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+
if block_given?
27+
original_applicable_params = @applicable_params
28+
original_parent_key_name = @parent_key_name
29+
@applicable_params = applicable_params[name]
30+
@parent_key_name = formatted_params(@parent_key_name, name)
31+
32+
yield
33+
34+
@applicable_params = original_applicable_params
35+
@parent_key_name = original_parent_key_name
36+
end
2437
rescue InvalidParameterError => exception
38+
exception_name = formatted_params(@parent_key_name, name)
2539
if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false)
26-
exception.param, exception.options = name, options
40+
exception.param = exception_name
41+
exception.options = options
2742
raise exception
2843
end
2944

3045
error = exception.to_s
3146

3247
if content_type and content_type.match(mime_type(:json))
33-
error = {message: error, errors: {name => exception.message}}.to_json
48+
error = {message: error, errors: {exception_name => exception.message}}.to_json
3449
end
3550

3651
halt 400, error
@@ -40,18 +55,19 @@ def param(name, type, options = {})
4055
def one_of(*args)
4156
options = args.last.is_a?(Hash) ? args.pop : {}
4257
names = args.collect(&:to_s)
58+
applicable_params = @applicable_params || params
4359

4460
return unless names.length >= 2
4561

4662
begin
47-
validate_one_of!(params, names, options)
63+
validate_one_of!(applicable_params, names, options)
4864
rescue InvalidParameterError => exception
4965
if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false)
5066
exception.param, exception.options = names, options
5167
raise exception
5268
end
5369

54-
error = "Invalid parameters [#{names.join(', ')}]"
70+
error = "Invalid parameters #{formatted_params(@parent_key_name, names)}"
5571
if content_type and content_type.match(mime_type(:json))
5672
error = {message: error, errors: {names => exception.message}}.to_json
5773
end
@@ -63,20 +79,22 @@ def one_of(*args)
6379
def any_of(*args)
6480
options = args.last.is_a?(Hash) ? args.pop : {}
6581
names = args.collect(&:to_s)
82+
applicable_params = @applicable_params || params
6683

6784
return unless names.length >= 2
6885

6986
begin
70-
validate_any_of!(params, names, options)
87+
validate_any_of!(applicable_params, names, options)
7188
rescue InvalidParameterError => exception
7289
if options[:raise] or (settings.raise_sinatra_param_exceptions rescue false)
7390
exception.param, exception.options = names, options
7491
raise exception
7592
end
7693

77-
error = "Invalid parameters [#{names.join(', ')}]"
94+
formatted_params = formatted_params(@parent_key_name, names)
95+
error = "Invalid parameters #{formatted_params}"
7896
if content_type and content_type.match(mime_type(:json))
79-
error = {message: error, errors: {names => exception.message}}.to_json
97+
error = {message: error, errors: {formatted_params => exception.message}}.to_json
8098
end
8199

82100
halt 400, error
@@ -143,11 +161,15 @@ def validate!(param, options)
143161
end
144162

145163
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
164+
if names.count{|name| present?(params[name])} > 1
165+
raise InvalidParameterError, "Only one of #{formatted_params(@parent_key_name, names)} is allowed"
166+
end
147167
end
148168

149169
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
170+
if names.count{|name| present?(params[name])} < 1
171+
raise InvalidParameterError, "One of parameters #{formatted_params(@parent_key_name, names)} is required"
172+
end
151173
end
152174

153175
# ActiveSupport #present? and #blank? without patching Object
@@ -158,6 +180,14 @@ def present?(object)
158180
def blank?(object)
159181
object.respond_to?(:empty?) ? object.empty? : !object
160182
end
183+
184+
def formatted_params(parent_key, name)
185+
if name.is_a?(Array)
186+
name = "[#{name.join(', ')}]"
187+
end
188+
189+
return parent_key ? "#{parent_key}[#{name}]" : name
190+
end
161191
end
162192

163193
helpers Param

spec/dummy/app.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,48 @@ 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 '/one_of/nested' do
282+
param :parent, Hash do
283+
param :a, String
284+
param :b, String
285+
param :c, String
286+
287+
one_of :a, :b, :c
288+
end
289+
290+
{
291+
message: 'OK'
292+
}.to_json
293+
end
294+
295+
get '/any_of/nested' do
296+
param :parent, Hash do
297+
param :a, String
298+
param :b, String
299+
param :c, String
300+
301+
any_of :a, :b, :c
302+
end
303+
304+
{
305+
message: 'OK'
306+
}.to_json
307+
end
264308
end
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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 work with one_of nested in a hash' do
81+
params = {
82+
:parent => {
83+
:a => 'test'
84+
}
85+
}
86+
87+
get("/one_of/nested", params) do |response|
88+
expect(response.status).to eq(200)
89+
expect(JSON.parse(response.body)['message']).to eq("OK")
90+
end
91+
end
92+
93+
it "should error when one_of isn't satisfied in a nested hash" do
94+
params = {
95+
:parent => {
96+
:a => 'test',
97+
:b => 'test'
98+
}
99+
}
100+
101+
get("/one_of/nested", params) do |response|
102+
expect(response.status).to eq(400)
103+
expect(JSON.parse(response.body)['message']).to eq("Invalid parameters parent[[a, b, c]]")
104+
end
105+
end
106+
107+
it 'should work with any_of nested in a hash' do
108+
params = {
109+
:parent => {
110+
:a => 'test'
111+
}
112+
}
113+
114+
get("/any_of/nested", params) do |response|
115+
expect(response.status).to eq(200)
116+
expect(JSON.parse(response.body)['message']).to eq("OK")
117+
end
118+
end
119+
120+
it "should error when one_of isn't satisfied in a nested hash" do
121+
params = {
122+
:parent => {
123+
:d => 'test'
124+
}
125+
}
126+
127+
get("/any_of/nested", params) do |response|
128+
expect(response.status).to eq(400)
129+
expect(JSON.parse(response.body)['message']).to eq("Invalid parameters parent[[a, b, c]]")
130+
end
131+
end
132+
133+
end
134+
end

0 commit comments

Comments
 (0)