diff --git a/README.markdown b/README.markdown index c7ea07f29b..3d8ce3baf1 100644 --- a/README.markdown +++ b/README.markdown @@ -192,6 +192,24 @@ post '/json_endpoint' do end ``` +## Validations + +You can define validations and coercion option for your attributes: + +```ruby +params do + required :id, type: Integer + optional :name, type: String, regexp: /^[a-z]+$/ +end + +get ':id' do + # params[:id] is an Integer +end +``` + +When a type is specified an implicit validation is done after the coercion to ensure the output type is what you asked. + + ## Headers Headers are available through the `env` hash object. diff --git a/grape.gemspec b/grape.gemspec index 133f7cdba2..e5ea244feb 100644 --- a/grape.gemspec +++ b/grape.gemspec @@ -20,6 +20,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'multi_json' s.add_runtime_dependency 'multi_xml' s.add_runtime_dependency 'hashie', '~> 1.2' + s.add_runtime_dependency 'virtus' s.add_development_dependency 'rake' s.add_development_dependency 'maruku' diff --git a/lib/grape.rb b/lib/grape.rb index 44d5bf8303..3b8e672bb9 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -9,6 +9,7 @@ module Grape autoload :Route, 'grape/route' autoload :Entity, 'grape/entity' autoload :Cookies, 'grape/cookies' + autoload :Validations, 'grape/validations' module Middleware autoload :Base, 'grape/middleware/base' diff --git a/lib/grape/api.rb b/lib/grape/api.rb index eec25f8e88..d4c46a0612 100644 --- a/lib/grape/api.rb +++ b/lib/grape/api.rb @@ -8,6 +8,8 @@ module Grape # creating Grape APIs.Users should subclass this # class in order to build an API. class API + extend Validations::ClassMethods + class << self attr_reader :route_set attr_reader :versions @@ -32,6 +34,7 @@ def reset! @endpoints = [] @mountings = [] @routes = nil + reset_validations! end def compile @@ -287,7 +290,9 @@ def route(methods, paths = ['/'], route_options = {}, &block) :route_options => (route_options || {}).merge(@last_description || {}) } endpoints << Grape::Endpoint.new(settings.clone, endpoint_options, &block) + @last_description = nil + reset_validations! end def before(&block) diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index 3ec4ad3eb0..d500fa18d2 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -285,6 +285,11 @@ def run(env) self.extend helpers cookies.read(@request) + + Array(settings[:validations]).each do |validator| + validator.validate!(params) + end + run_filters befores response_text = instance_eval &self.block run_filters afters diff --git a/lib/grape/validations.rb b/lib/grape/validations.rb new file mode 100644 index 0000000000..fa7a9991dc --- /dev/null +++ b/lib/grape/validations.rb @@ -0,0 +1,154 @@ +require 'virtus' + +module Grape + + module Validations + + ## + # All validators must inherit from this class. + # + class Validator + def initialize(attrs, options) + @attrs = Array(attrs) + + if options.is_a?(Hash) && !options.empty? + raise "unknown options: #{options.keys}" + end + end + + def validate!(params) + @attrs.each do |attr_name| + validate_param!(attr_name, params) + end + end + + private + + def self.convert_to_short_name(klass) + ret = klass.name.gsub(/::/, '/'). + gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). + gsub(/([a-z\d])([A-Z])/,'\1_\2'). + tr("-", "_"). + downcase + File.basename(ret, '_validator') + end + end + + ## + # Base class for all validators taking only one param. + class SingleOptionValidator < Validator + def initialize(attrs, options) + @option = options + super + end + + end + + # we define Validator::inherited here so SingleOptionValidator + # will not be considered a validator. + class Validator + def self.inherited(klass) + short_name = convert_to_short_name(klass) + Validations::register_validator(short_name, klass) + end + end + + + + class < true} + if attrs.last.is_a?(Hash) + validations.merge!(attrs.pop) + end + + validates(attrs, validations) + end + + def optional(*attrs) + validations = {} + if attrs.last.is_a?(Hash) + validations.merge!(attrs.pop) + end + + validates(attrs, validations) + end + + private + def validates(attrs, validations) + doc_attrs = { :required => validations.keys.include?(:presence) } + + # special case (type = coerce) + if validations[:type] + validations[:coerce] = validations.delete(:type) + end + + if coerce_type = validations[:coerce] + doc_attrs[:type] = coerce_type.to_s + end + + if desc = validations.delete(:desc) + doc_attrs[:desc] = desc + end + + @api.document_attribute(attrs, doc_attrs) + + validations.each do |type, options| + validator_class = Validations::validators[type.to_s] + if validator_class + @api.settings[:validations] << validator_class.new(attrs, options) + else + raise "unknown validator: #{type}" + end + end + + end + + end + + # This module is mixed into the API Class. + module ClassMethods + def reset_validations! + settings[:validations] = [] + end + + def params(&block) + ParamsScope.new(self, &block) + end + + def document_attribute(names, opts) + if @last_description + @last_description[:params] ||= {} + + Array(names).each do |name| + @last_description[:params][name.to_sym] ||= {} + @last_description[:params][name.to_sym].merge!(opts) + end + end + end + + end + + end +end + +# load all defined validations +Dir[File.expand_path('../validations/*.rb', __FILE__)].each do |path| + require(path) +end diff --git a/lib/grape/validations/coerce.rb b/lib/grape/validations/coerce.rb new file mode 100644 index 0000000000..865a4f045f --- /dev/null +++ b/lib/grape/validations/coerce.rb @@ -0,0 +1,56 @@ + +module Grape + class API + Boolean = Virtus::Attribute::Boolean + end + + module Validations + + class CoerceValidator < SingleOptionValidator + def validate_param!(attr_name, params) + new_value = coerce_value(@option, params[attr_name]) + if valid_type?(new_value) + params[attr_name] = new_value + else + throw :error, :status => 400, :message => "invalid parameter: #{attr_name}" + end + end + + private + def _valid_array_type?(type, values) + values.all? do |val| + _valid_single_type?(type, val) + end + end + + + def _valid_single_type?(klass, val) + if klass == Virtus::Attribute::Boolean + val.is_a?(TrueClass) || val.is_a?(FalseClass) + else + val.is_a?(klass) + end + end + + def valid_type?(val) + if @option.is_a?(Array) + _valid_array_type?(@option[0], val) + else + _valid_single_type?(@option, val) + end + end + + def coerce_value(type, val) + converter = Virtus::Attribute.build(:a, type) + converter.coerce(val) + + # not the prettiest but some invalid coercion can currently trigger + # errors in Virtus (see coerce_spec.rb) + rescue => err + nil + end + + end + + end +end diff --git a/lib/grape/validations/presence.rb b/lib/grape/validations/presence.rb new file mode 100644 index 0000000000..a6871e2086 --- /dev/null +++ b/lib/grape/validations/presence.rb @@ -0,0 +1,14 @@ +module Grape + module Validations + + class PresenceValidator < Validator + def validate_param!(attr_name, params) + unless params.has_key?(attr_name) + throw :error, :status => 400, :message => "missing parameter: #{attr_name}" + end + end + + end + + end +end diff --git a/lib/grape/validations/regexp.rb b/lib/grape/validations/regexp.rb new file mode 100644 index 0000000000..9b1081730c --- /dev/null +++ b/lib/grape/validations/regexp.rb @@ -0,0 +1,13 @@ +module Grape + module Validations + + class RegexpValidator < SingleOptionValidator + def validate_param!(attr_name, params) + if params[attr_name] && !( params[attr_name].to_s =~ @option ) + throw :error, :status => 400, :message => "invalid parameter: #{attr_name}" + end + end + end + + end +end diff --git a/spec/grape/validations/coerce_spec.rb b/spec/grape/validations/coerce_spec.rb new file mode 100644 index 0000000000..087f214efa --- /dev/null +++ b/spec/grape/validations/coerce_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +require 'virtus' + +module CoerceTest + class User + include Virtus + + attribute :id, Integer + attribute :name, String + end + + class API < Grape::API + default_format :json + + + params do + requires :int, :coerce => Integer + end + get '/single' do + end + + params do + requires :ids, type: Array[Integer] + end + get '/arr' do + end + + params do + requires :user, type: CoerceTest::User + end + get '/user' do + end + + params do + requires :int, :coerce => Integer + optional :arr, :coerce => Array[Integer] + optional :bool, :coerce => Array[Boolean] + end + get '/coerce' do + { + :int => params[:int].class, + :arr => params[:arr] ? params[:arr][0].class : nil, + :bool => params[:bool] ? (params[:bool][0] == true) && (params[:bool][1] == false) : nil + } + end + end +end + +describe Grape::Validations::CoerceValidator do + def app + CoerceTest::API + end + + it "should return an error on malformed input" do + get '/single', :int => "43a" + last_response.status.should == 400 + + get '/single', :int => "43" + last_response.status.should == 200 + end + + it "should return an error on malformed input (array)" do + get '/arr', :ids => ["1", "2", "az"] + last_response.status.should == 400 + + get '/arr', :ids => ["1", "2", "890"] + last_response.status.should == 200 + end + + it "should return an error on malformed input (complex object)" do + # this request does raise an error inside Virtus + get '/user', :user => "32" + last_response.status.should == 400 + + get '/user', :user => {id: 32, name: "Bob"} + last_response.status.should == 200 + end + + it 'should coerce inputs' do + get('/coerce', :int => "43") + last_response.status.should == 200 + ret = MultiJson.load(last_response.body) + ret["int"].should == "Fixnum" + + get('/coerce', :int => "40", :arr => ["1","20","3"], :bool => [1, 0]) + # last_response.body.should == "" + last_response.status.should == 200 + ret = MultiJson.load(last_response.body) + ret["int"].should == "Fixnum" + ret["arr"].should == "Fixnum" + ret["bool"].should == true + end + +end diff --git a/spec/grape/validations/presence_spec.rb b/spec/grape/validations/presence_spec.rb new file mode 100644 index 0000000000..932bd1fcf9 --- /dev/null +++ b/spec/grape/validations/presence_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Grape::Validations::PresenceValidator do + def app; @app; end + + before do + @app = Class.new(Grape::API) do + default_format :json + + params do + requires :id, :regexp => /^[0-9]+$/ + end + + post do + {:ret => params[:id]} + end + + params do + requires :name, :company + end + + get do + "Hello" + end + + end + + end + + it 'validates id' do + post('/') + last_response.status.should == 400 + last_response.body.should == "missing parameter: id" + + post('/', {}, 'rack.input' => StringIO.new('{"id" : "a56b"}')) + last_response.body.should == 'invalid parameter: id' + last_response.status.should == 400 + + post('/', {}, 'rack.input' => StringIO.new('{"id" : 56}')) + last_response.body.should == '{"ret":56}' + last_response.status.should == 201 + end + + it 'validates name, company' do + get('/') + last_response.status.should == 400 + last_response.body.should == "missing parameter: name" + + get('/', :name => "Bob") + last_response.status.should == 400 + last_response.body.should == "missing parameter: company" + + get('/', :name => "Bob", :company => "TestCorp") + last_response.status.should == 200 + last_response.body.should == "Hello" + end + +end diff --git a/spec/grape/validations/regexp_spec.rb b/spec/grape/validations/regexp_spec.rb new file mode 100644 index 0000000000..9c656dc515 --- /dev/null +++ b/spec/grape/validations/regexp_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Grape::Validations::RegexpValidator do + def app; @app; end + + before do + @app = Class.new(Grape::API) do + default_format :json + + params do + requires :name, :regexp => /^[a-z]+$/ + end + get do + + end + + end + + end + + it 'should refuse invalid input' do + get '/', :name => "invalid name" + last_response.status.should == 400 + end + + it 'should accept valid input' do + get '/', :name => "bob" + last_response.status.should == 200 + end + +end diff --git a/spec/grape/validations_spec.rb b/spec/grape/validations_spec.rb new file mode 100644 index 0000000000..fd998875b0 --- /dev/null +++ b/spec/grape/validations_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Grape::Validations do + def app; @app; end + + before do + @app = Class.new(Grape::API) do + default_format :json + + params do + requires :name, :company + optional :a_number, :regexp => /^[0-9]+$/ + end + + get do + "Hello" + end + + end + + end + + it 'validates optional parameter if present' do + get('/', :name => "Bob", :company => "TestCorp", :a_number => "string") + last_response.status.should == 400 + last_response.body.should == "invalid parameter: a_number" + + get('/', :name => "Bob", :company => "TestCorp", :a_number => 45) + last_response.status.should == 200 + last_response.body.should == "Hello" + end + +end