diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index 8e1ad5bd2c..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). @@ -10,6 +11,7 @@ Next Release * [#203](https://github.com/intridea/grape/pull/203): Added a check to `Entity#serializable_hash` that verifies an entity exists on an object - [@adamgotterer](https://github.com/adamgotterer). * [#204](https://github.com/intridea/grape/pull/204): Added ability to declare shared parameters at namespace level - [@tim-vandecasteele](https://github.com/tim-vandecasteele). * [#208](https://github.com/intridea/grape/pull/208): `Entity#serializable_hash` must also check if attribute is generated by a user supplied block - [@ppadron](https://github.com/ppadron). +* [#234](https://github.com/intridea/grape/pull/234): Adds a DSL for creating entities via mixin - [@mbleigh](https://github.com/mbleigh). 0.2.1 (7/11/2012) ================= diff --git a/README.markdown b/README.markdown index dfc83493ab..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. @@ -604,6 +612,27 @@ module API end ``` +#### Using the Exposure DSL + +Grape ships with a DSL to easily define entities within the context +of an existing class: + +```ruby +class User + include Grape::Entity::DSL + + entity :name, :email do + expose :advanced, if: :conditional + end +end +``` + +The above will automatically create a `User::Entity` class and +define properties on it according to the same rules as above. If +you only want to define simple exposures you don't have to supply +a block and can instead simply supply a list of comma-separated +symbols. + ### Using Entities Once an entity is defined, it can be used within endpoints, by calling #present. The #present @@ -629,6 +658,32 @@ module API end ``` +### Entity Organization + +In addition to separately organizing entities, it may be useful to +put them as namespaced classes underneath the model they represent. +For example: + +```ruby +class User + def entity + Entity.new(self) + end + + class Entity < Grape::Entity + expose :name, :email + end +end +``` + +If you organize your entities this way, Grape will automatically +detect the `Entity` class and use it to present your models. In +this example, if you added `present User.new` to your endpoint, +Grape would automatically detect that there is a `User::Entity` +class and use that as the representative entity. This can still +be overridden by using the `:with` option or an explicit +`represents` call. + ### Caveats Entities with duplicate exposure names and conditions will silently overwrite one another. diff --git a/lib/grape/api.rb b/lib/grape/api.rb index 6b314b0c9d..17d55f7acb 100644 --- a/lib/grape/api.rb +++ b/lib/grape/api.rb @@ -386,6 +386,7 @@ def nest(*blocks, &block) instance_eval &block if block_given? blocks.each{|b| instance_eval &b} settings.pop # when finished, we pop the context + reset_validations! else instance_eval &block end diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index 535cf46395..8216cad1e1 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -255,6 +255,8 @@ def present(object, options = {}) entity_class ||= (settings[:representations] || {})[potential] end + entity_class ||= object.class.const_get(:Entity) if object.class.const_defined?(:Entity) + root = options.delete(:root) representation = if entity_class diff --git a/lib/grape/entity.rb b/lib/grape/entity.rb index ee114cedce..72c0cf9e70 100644 --- a/lib/grape/entity.rb +++ b/lib/grape/entity.rb @@ -43,6 +43,60 @@ module Grape class Entity attr_reader :object, :options + # The Entity DSL allows you to mix entity functionality into + # your existing classes. + module DSL + def self.included(base) + base.extend ClassMethods + ancestor_entity_class = base.ancestors.detect{|a| a.entity_class if a.respond_to?(:entity_class)} + base.const_set(:Entity, Class.new(ancestor_entity_class || Grape::Entity)) unless const_defined?(:Entity) + end + + module ClassMethods + # Returns the automatically-created entity class for this + # Class. + def entity_class(search_ancestors=true) + klass = const_get(:Entity) if const_defined?(:Entity) + klass ||= ancestors.detect{|a| a.entity_class(false) if a.respond_to?(:entity_class) } if search_ancestors + klass + end + + # Call this to make exposures to the entity for this Class. + # Can be called with symbols for the attributes to expose, + # a block that yields the full Entity DSL (See Grape::Entity), + # or both. + # + # @example Symbols only. + # + # class User + # include Grape::Entity::DSL + # + # entity :name, :email + # end + # + # @example Mixed. + # + # class User + # include Grape::Entity::DSL + # + # entity :name, :email do + # expose :latest_status, using: Status::Entity, if: :include_status + # expose :new_attribute, :if => {:version => 'v2'} + # end + # end + def entity(*exposures, &block) + entity_class.expose *exposures if exposures.any? + entity_class.class_eval(&block) if block_given? + entity_class + end + end + + # Instantiates an entity version of this object. + def entity + self.class.entity_class.new(self) + end + end + # This method is the primary means by which you will declare what attributes # should be exposed by the entity. # @@ -259,7 +313,7 @@ def serializable_hash(runtime_options = {}) return nil if object.nil? opts = options.merge(runtime_options || {}) exposures.inject({}) do |output, (attribute, exposure_options)| - if exposure_options.has_key?(:proc) || object.respond_to?(attribute) && conditions_met?(exposure_options, opts) + if (exposure_options.has_key?(:proc) || object.respond_to?(attribute)) && conditions_met?(exposure_options, opts) partial_output = value_for(attribute, opts) output[key_for(attribute)] = if partial_output.respond_to? :serializable_hash diff --git a/lib/grape/validations.rb b/lib/grape/validations.rb index 6ae6ec2636..687c4fa49e 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,17 +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) - end + @last_description ||= {} + @last_description[:params] ||= {} + + Array(names).each do |name| + @last_description[:params][name[:name].to_s] ||= {} + @last_description[:params][name[:name].to_s].merge!(opts).merge!({:full_name => name[:full_name]}) end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index afb08cc852..3d88623b4f 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,33 @@ 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 parse parameters when no description is given" do + subject.params do + requires :one_param, :desc => "one param" + end + subject.get "method" do ; end + subject.routes.map { |route| + { :description => route.route_description, :params => route.route_params } + }.should eq [ + { :description => nil, :params => { "one_param" => { :required => true, :desc => "one param", :full_name=>"one_param" } } } ] end it "should not symbolize params" do diff --git a/spec/grape/endpoint_spec.rb b/spec/grape/endpoint_spec.rb index d6ff1c5e4e..0f27fbb174 100644 --- a/spec/grape/endpoint_spec.rb +++ b/spec/grape/endpoint_spec.rb @@ -347,6 +347,20 @@ def memoized last_response.body.should == 'Hiya' end + it 'should automatically use Klass::Entity if that exists' do + some_model = Class.new + entity = Class.new(Grape::Entity) + entity.stub!(:represent).and_return("Auto-detect!") + + some_model.const_set :Entity, entity + + subject.get '/example' do + present some_model.new + end + get '/example' + last_response.body.should == 'Auto-detect!' + end + it 'should add a root key to the output if one is given' do subject.get '/example' do present({:abc => 'def'}, :root => :root) diff --git a/spec/grape/entity_spec.rb b/spec/grape/entity_spec.rb index a108f0c53e..198e3710e0 100644 --- a/spec/grape/entity_spec.rb +++ b/spec/grape/entity_spec.rb @@ -253,8 +253,6 @@ describe '#serializable_hash' do - - it 'should not throw an exception if a nil options object is passed' do expect{ fresh_class.new(model).serializable_hash(nil) }.not_to raise_error end @@ -296,6 +294,14 @@ res = fresh_class.new(model).serializable_hash res.should have_key :non_existant_attribute end + + it "should not expose attributes that are generated by a block but have not passed criteria" do + fresh_class.expose :non_existant_attribute, :proc => lambda {|model, options| + "I exist, but it is not yet my time to shine" + }, :if => lambda { |model, options| false } + res = fresh_class.new(model).serializable_hash + res.should_not have_key :non_existant_attribute + end context "#serializable_hash" do @@ -472,5 +478,53 @@ class FriendEntity < Grape::Entity subject.send(:conditions_met?, exposure_options, :true => true).should be_false end end + + describe "::DSL" do + subject{ Class.new } + + it 'should create an Entity class when called' do + subject.should_not be_const_defined(:Entity) + subject.send(:include, Grape::Entity::DSL) + subject.should be_const_defined(:Entity) + end + + context 'pre-mixed' do + before{ subject.send(:include, Grape::Entity::DSL) } + + it 'should be able to define entity traits through DSL' do + subject.entity do + expose :name + end + + subject.entity_class.exposures.should_not be_empty + end + + it 'should be able to expose straight from the class' do + subject.entity :name, :email + subject.entity_class.exposures.size.should == 2 + end + + it 'should be able to mix field and advanced exposures' do + subject.entity :name, :email do + expose :third + end + subject.entity_class.exposures.size.should == 3 + end + + context 'instance' do + let(:instance){ subject.new } + + describe '#entity' do + it 'should be an instance of the entity class' do + instance.entity.should be_kind_of(subject.entity_class) + end + + it 'should have an object of itself' do + instance.entity.object.should == instance + end + end + end + end + end end end 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 2dd5f54e8a..6680a817e9 100644 --- a/spec/grape/validations/presence_spec.rb +++ b/spec/grape/validations/presence_spec.rb @@ -6,7 +6,13 @@ module ValidationsSpec module PresenceValidatorSpec class API < Grape::API default_format :json - + + resource :bacons do + get do + "All the bacon" + end + end + params do requires :id, :regexp => /^[0-9]+$/ end @@ -20,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 @@ -27,6 +56,12 @@ class API < Grape::API def app ValidationsSpec::PresenceValidatorSpec::API end + + it "does not validate for any params" do + get("/bacons") + last_response.status.should == 200 + last_response.body.should == "All the bacon" + end it 'validates id' do post('/') @@ -55,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