diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index 922a765d52..8239f2595f 100644 --- a/CHANGELOG.markdown +++ b/CHANGELOG.markdown @@ -1,6 +1,7 @@ Next Release ============ +* [#236](https://github.com/intridea/grape/pull/236): Allow validation of nested parameters. - [@tim-vandecasteele](https://github.com/tim-vandecasteele). * [#201](https://github.com/intridea/grape/pull/201): Added custom exceptions to Grape. Updated validations to use ValidationError that can be rescued. - [@adamgotterer](https://github.com/adamgotterer). * [#211](https://github.com/intridea/grape/pull/211): Updates to validation and coercion: Fix #211 and force order of operations for presence and coercion - [@adamgotterer](https://github.com/adamgotterer). * [#210](https://github.com/intridea/grape/pull/210): Fix: `Endpoint#body_params` causing undefined method 'size' - [@adamgotterer](https://github.com/adamgotterer). diff --git a/README.markdown b/README.markdown index d319c05c0c..558de1004e 100644 --- a/README.markdown +++ b/README.markdown @@ -220,6 +220,11 @@ You can define validations and coercion options for your parameters using `param params do requires :id, type: Integer optional :name, type: String, regexp: /^[a-z]+$/ + + group :user do + requires :first_name + requires :last_name + end end get ':id' do # params[:id] is an Integer @@ -229,6 +234,9 @@ end When a type is specified an implicit validation is done after the coercion to ensure the output type is the one declared. +Parameters can be nested using `group`. In the above example, this means both +`params[:user][:first_name]` and `params[:user][:last_name]` are required next to `params[:id]`. + ### Namespace Validation and Coercion Namespaces allow parameter definitions and apply to every method within the namespace. diff --git a/lib/grape/validations.rb b/lib/grape/validations.rb index 6ae6ec2636..6c29f870a8 100644 --- a/lib/grape/validations.rb +++ b/lib/grape/validations.rb @@ -8,9 +8,10 @@ module Validations # All validators must inherit from this class. # class Validator - def initialize(attrs, options, required) + def initialize(attrs, options, required, scope) @attrs = Array(attrs) @required = required + @scope = scope if options.is_a?(Hash) && !options.empty? raise "unknown options: #{options.keys}" @@ -18,6 +19,8 @@ def initialize(attrs, options, required) end def validate!(params) + params = @scope.params(params) + @attrs.each do |attr_name| if @required || params.has_key?(attr_name) validate_param!(attr_name, params) @@ -40,7 +43,7 @@ def self.convert_to_short_name(klass) ## # Base class for all validators taking only one param. class SingleOptionValidator < Validator - def initialize(attrs, options, required) + def initialize(attrs, options, required, scope) @option = options super end @@ -67,7 +70,11 @@ def self.register_validator(short_name, klass) end class ParamsScope - def initialize(api, &block) + attr_accessor :element, :parent + + def initialize(api, element, parent, &block) + @element = element + @parent = parent @api = api instance_eval(&block) end @@ -89,7 +96,22 @@ def optional(*attrs) validates(attrs, validations) end - + + def group(element, &block) + scope = ParamsScope.new(@api, element, self, &block) + end + + def params(params) + params = @parent.params(params) if @parent + params = params[@element] || {} if @element + params + end + + def full_name(name) + return "#{@parent.full_name(@element)}[#{name}]" if @parent + name.to_s + end + private def validates(attrs, validations) doc_attrs = { :required => validations.keys.include?(:presence) } @@ -106,9 +128,10 @@ def validates(attrs, validations) if desc = validations.delete(:desc) doc_attrs[:desc] = desc end - - @api.document_attribute(attrs, doc_attrs) - + + full_attrs = attrs.collect{ |name| { :name => name, :full_name => full_name(name)} } + @api.document_attribute(full_attrs, doc_attrs) + # Validate for presence before any other validators if validations.has_key?(:presence) && validations[:presence] validate('presence', validations[:presence], attrs, doc_attrs) @@ -130,7 +153,7 @@ def validates(attrs, validations) def validate(type, options, attrs, doc_attrs) validator_class = Validations::validators[type.to_s] if validator_class - @api.settings[:validations] << validator_class.new(attrs, options, doc_attrs[:required]) + @api.settings[:validations] << validator_class.new(attrs, options, doc_attrs[:required], self) else raise "unknown validator: #{type}" end @@ -145,16 +168,16 @@ def reset_validations! end def params(&block) - ParamsScope.new(self, &block) + ParamsScope.new(self, nil, nil, &block) end def document_attribute(names, opts) if @last_description @last_description[:params] ||= {} - + Array(names).each do |name| - @last_description[:params][name.to_s] ||= {} - @last_description[:params][name.to_s].merge!(opts) + @last_description[:params][name[:name].to_s] ||= {} + @last_description[:params][name[:name].to_s].merge!(opts).merge!({:full_name => name[:full_name]}) end end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index afb08cc852..c811d51ab0 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -1029,7 +1029,7 @@ class CommunicationError < RuntimeError; end subject.routes.map { |route| { :description => route.route_description, :params => route.route_params } }.should eq [ - { :description => "method", :params => { "ns_param" => { :required => true, :desc => "namespace parameter" }, "method_param" => { :required => false, :desc => "method parameter" } } } + { :description => "method", :params => { "ns_param" => { :required => true, :desc => "namespace parameter", :full_name=>"ns_param" }, "method_param" => { :required => false, :desc => "method parameter", :full_name=>"method_param" } } } ] end it "should merge the parameters of nested namespaces" do @@ -1055,7 +1055,22 @@ class CommunicationError < RuntimeError; end subject.routes.map { |route| { :description => route.route_description, :params => route.route_params } }.should eq [ - { :description => "method", :params => { "ns_param" => { :required => true, :desc => "ns param 2" }, "ns1_param" => { :required => true, :desc => "ns1 param" }, "ns2_param" => { :required => true, :desc => "ns2 param" }, "method_param" => { :required => false, :desc => "method param" } } } + { :description => "method", :params => { "ns_param" => { :required => true, :desc => "ns param 2", :full_name=>"ns_param" }, "ns1_param" => { :required => true, :desc => "ns1 param", :full_name=>"ns1_param" }, "ns2_param" => { :required => true, :desc => "ns2 param", :full_name=>"ns2_param" }, "method_param" => { :required => false, :desc => "method param", :full_name=>"method_param" } } } + ] + end + it "should provide a full_name for parameters in nested groups" do + subject.desc "nesting" + subject.params do + requires :root_param, :desc => "root param" + group :nested do + requires :nested_param, :desc => "nested param" + end + end + subject.get "method" do ; end + subject.routes.map { |route| + { :description => route.route_description, :params => route.route_params } + }.should eq [ + { :description => "nesting", :params => { "root_param" => { :required => true, :desc => "root param", :full_name=>"root_param" }, "nested_param" => { :required => true, :desc => "nested param", :full_name=>"nested[nested_param]" } } } ] end it "should not symbolize params" do diff --git a/spec/grape/validations/coerce_spec.rb b/spec/grape/validations/coerce_spec.rb index f7ba16bf0a..f218d0b230 100644 --- a/spec/grape/validations/coerce_spec.rb +++ b/spec/grape/validations/coerce_spec.rb @@ -111,6 +111,19 @@ class User last_response.status.should == 201 last_response.body.should == File.basename(__FILE__).to_s end + + it 'Nests integers' do + subject.params do + group :integers do + requires :int, :coerce => Integer + end + end + subject.get '/int' do params[:integers][:int].class; end + + get '/int', { :integers => { :int => "45" } } + last_response.status.should == 200 + last_response.body.should == 'Fixnum' + end end end end diff --git a/spec/grape/validations/presence_spec.rb b/spec/grape/validations/presence_spec.rb index a8b2ba74e3..6680a817e9 100644 --- a/spec/grape/validations/presence_spec.rb +++ b/spec/grape/validations/presence_spec.rb @@ -26,6 +26,29 @@ class API < Grape::API get do "Hello" end + + params do + group :user do + requires :first_name, :last_name + end + end + get '/nested' do + "Nested" + end + + params do + group :admin do + requires :admin_name + group :super do + group :user do + requires :first_name, :last_name + end + end + end + end + get '/nested_triple' do + "Nested triple" + end end end end @@ -67,5 +90,49 @@ def app last_response.status.should == 200 last_response.body.should == "Hello" end - + + it 'validates nested parameters' do + get('/nested') + last_response.status.should == 400 + last_response.body.should == "missing parameter: first_name" + + get('/nested', :user => {:first_name => "Billy"}) + last_response.status.should == 400 + last_response.body.should == "missing parameter: last_name" + + get('/nested', :user => {:first_name => "Billy", :last_name => "Bob"}) + last_response.status.should == 200 + last_response.body.should == "Nested" + end + + it 'validates triple nested parameters' do + get('/nested_triple') + last_response.status.should == 400 + last_response.body.should == "missing parameter: admin_name" + + get('/nested_triple', :user => {:first_name => "Billy"}) + last_response.status.should == 400 + last_response.body.should == "missing parameter: admin_name" + + get('/nested_triple', :admin => {:super => {:first_name => "Billy"}}) + last_response.status.should == 400 + last_response.body.should == "missing parameter: admin_name" + + get('/nested_triple', :super => {:user => {:first_name => "Billy", :last_name => "Bob"}}) + last_response.status.should == 400 + last_response.body.should == "missing parameter: admin_name" + + get('/nested_triple', :admin => {:super => {:user => {:first_name => "Billy"}}}) + last_response.status.should == 400 + last_response.body.should == "missing parameter: admin_name" + + get('/nested_triple', :admin => { :admin_name => 'admin', :super => {:user => {:first_name => "Billy"}}}) + last_response.status.should == 400 + last_response.body.should == "missing parameter: last_name" + + get('/nested_triple', :admin => { :admin_name => 'admin', :super => {:user => {:first_name => "Billy", :last_name => "Bob"}}}) + last_response.status.should == 200 + last_response.body.should == "Nested triple" + end + end