Skip to content

Commit

Permalink
allow validation of nested parameters through block
Browse files Browse the repository at this point in the history
  • Loading branch information
Cody Wright committed Oct 30, 2015
1 parent b856928 commit 449762e
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 14 deletions.
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
58 changes: 44 additions & 14 deletions lib/sinatra/param.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,39 @@ 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?
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 +55,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 +79,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 +161,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 +180,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
44 changes: 44 additions & 0 deletions spec/dummy/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,48 @@ 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 '/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
134 changes: 134 additions & 0 deletions spec/parameter_nested_validations_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 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

0 comments on commit 449762e

Please sign in to comment.