From 6c87ee93229e5c6dde405a62cbca1e4e4cf19b26 Mon Sep 17 00:00:00 2001 From: sethers Date: Tue, 7 Jan 2014 17:29:53 -0500 Subject: [PATCH 01/13] Treat object as a Hash to get attribute value Models that use single-table inheritance will be cast as specific classes that don't always have the attribute defined as a method. Checking to see if the `object` responds like a Hash allows more flexibility to get raw column data. We raise an `ArgumentError` to indicate that the attribute may be invalid. --- lib/grape_entity/entity.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index ed2c1ce5..4eb74bff 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -407,8 +407,12 @@ def value_for(attribute, options = {}) def delegate_attribute(attribute) if respond_to?(attribute, true) send(attribute) - else + elsif object.respond_to?(attribute, true) object.send(attribute) + elsif object.respond_to?(:[], true) + object.send(:[], attribute) + else + raise ArgumentError, ":attribute was unable to be found anywhere" end end From 660893e51bcb6bec1c3e90b91774409799f82975 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Tue, 7 Jan 2014 20:27:22 -0500 Subject: [PATCH 02/13] Added the ability to specify an alternate object to get values from via :object option --- lib/grape_entity/entity.rb | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 4eb74bff..1ed66552 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -388,29 +388,38 @@ def value_for(attribute, options = {}) using_options = options.dup using_options.delete(:collection) using_options[:root] = nil - exposure_options[:using].represent(delegate_attribute(attribute), using_options) + exposure_options[:using].represent(delegate_attribute(attribute, exposure_options[:object]), using_options) elsif exposure_options[:format_with] format_with = exposure_options[:format_with] if format_with.is_a?(Symbol) && formatters[format_with] - instance_exec(delegate_attribute(attribute), &formatters[format_with]) + instance_exec(delegate_attribute(attribute, exposure_options[:object]), &formatters[format_with]) elsif format_with.is_a?(Symbol) - send(format_with, delegate_attribute(attribute)) + send(format_with, delegate_attribute(attribute, exposure_options[:object])) elsif format_with.respond_to? :call - instance_exec(delegate_attribute(attribute), &format_with) + instance_exec(delegate_attribute(attribute, exposure_options[:object]), &format_with) end else - delegate_attribute(attribute) + delegate_attribute(attribute, exposure_options[:object]) end end - def delegate_attribute(attribute) + def delegate_attribute(attribute, alternate_object = nil) + target_object = case alternate_object + when Symbol + send(alternate_object) + when Proc + instance_exec(&alternate_object) + else + object + end + if respond_to?(attribute, true) send(attribute) - elsif object.respond_to?(attribute, true) - object.send(attribute) - elsif object.respond_to?(:[], true) - object.send(:[], attribute) + elsif target_object.respond_to?(attribute, true) + target_object.send(attribute) + elsif target_object.respond_to?(:[], true) + target_object.send(:[], attribute) else raise ArgumentError, ":attribute was unable to be found anywhere" end From a09c69a9e3ad3e4ba9c2ba6b88a2a5a8b9208987 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Tue, 7 Jan 2014 20:27:41 -0500 Subject: [PATCH 03/13] Added ability to merge exposures from child entity into parent to "flatten" --- lib/grape_entity/entity.rb | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 1ed66552..15b07b24 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -153,6 +153,58 @@ def self.with_options(options) yield @block_options.pop end + + # Merge exposures from another entity into the current entity + # as a way to "flatten" multiple models for use in formats such as "CSV". + # + # @example Merge child entity into parent + # + # class Address < Grape::Entity + # expose :street, :city, :state, :zip + # end + # + # class Contact < Grape::Entity + # expose :name + # expose :addresses, using: Address, unless: { format: :csv } + # merge_with Address, if: { format: :csv } do + # object.addresses.first + # end + # end + def self.merge_with(*entity_classes, &block) + merge_options = entity_classes.last.is_a?(Hash) ? entity_classes.pop.dup : {} + except_attributes = [merge_options.delete(:except)].compact + only_attributes = [merge_options.delete(:only)].compact + prefix = merge_options.delete(:prefix) + suffix = merge_options.delete(:suffix) + + merge_options[:object] = block if block_given? + + entity_classes.each do |entity_class| + raise ArgumentError, "#{entity_class} must be a Grape::Entity" unless entity_class < Grape::Entity + + merged_entities[entity_class] = merge_options + + entity_class.exposures.each_pair do |attribute, expose_options| + next if except_attributes.include?(attribute) + next if only_attributes.any? && !only_attributes.include?(attribute) + + options = expose_options.dup.merge(merge_options) + + [:if, :unless].each do |condition| + if merge_options.has_key?(condition) && expose_options.has_key?(condition) + options[condition] = Proc.new{|object, instance_options| conditions_met?(merge_options, instance_options) && conditions_met?(expose_options, instance_options)} + end + end + + expose :"#{prefix}#{attribute}#{suffix}", options + end + + end + end + + def self.merged_entities + @merged_entities ||= superclass.respond_to?(:merged_entities) ? superclass.exposures.dup : {} + end # Returns a hash of exposures that have been declared for this Entity or ancestors. The keys # are symbolized references to methods on the containing object, the values are From df53846b8ecdeeefcdb955f86047cbc3a9e77f99 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 21:58:46 -0500 Subject: [PATCH 04/13] Select object to get attribute value from based on :object option --- lib/grape_entity/entity.rb | 49 +++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 15b07b24..975e7e84 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -440,31 +440,33 @@ def value_for(attribute, options = {}) using_options = options.dup using_options.delete(:collection) using_options[:root] = nil - exposure_options[:using].represent(delegate_attribute(attribute, exposure_options[:object]), using_options) + exposure_options[:using].represent(delegate_attribute(attribute, exposure_options), using_options) elsif exposure_options[:format_with] format_with = exposure_options[:format_with] if format_with.is_a?(Symbol) && formatters[format_with] - instance_exec(delegate_attribute(attribute, exposure_options[:object]), &formatters[format_with]) + instance_exec(delegate_attribute(attribute, exposure_options), &formatters[format_with]) elsif format_with.is_a?(Symbol) - send(format_with, delegate_attribute(attribute, exposure_options[:object])) + send(format_with, delegate_attribute(attribute, exposure_options)) elsif format_with.respond_to? :call - instance_exec(delegate_attribute(attribute, exposure_options[:object]), &format_with) + instance_exec(delegate_attribute(attribute, exposure_options), &format_with) end else - delegate_attribute(attribute, exposure_options[:object]) + delegate_attribute(attribute, exposure_options) end end - - def delegate_attribute(attribute, alternate_object = nil) - target_object = case alternate_object - when Symbol - send(alternate_object) - when Proc - instance_exec(&alternate_object) - else - object - end + + # Detects what target object to retrieve the attribute value from. + # + # @param attribute [Symbol] Name of attribute to get a value from the target object + # @param alternate_object [Symbol, Proc] Specifies a target object to use + # instead of [#object] by referencing a method on the instance with a symbol, + # or evaluating a [Proc] and using the result as the target object. The original + # [#object] is used if no alternate object is specified. + # + # @raise [AttributeNotFoundError] + def delegate_attribute(attribute, options = {}) + target_object = select_target_object(options) if respond_to?(attribute, true) send(attribute) @@ -476,6 +478,19 @@ def delegate_attribute(attribute, alternate_object = nil) raise ArgumentError, ":attribute was unable to be found anywhere" end end + + def select_target_object(options) + alternate_object = options[:object] + + case alternate_object + when Symbol + send(alternate_object) + when Proc + instance_exec(&alternate_object) + else + object + end + end def valid_exposure?(attribute, exposure_options) exposure_options.has_key?(:proc) || \ @@ -490,7 +505,7 @@ def conditions_met?(exposure_options, options) if_conditions.each do |if_condition| case if_condition when Hash then if_condition.each_pair { |k, v| return false if options[k.to_sym] != v } - when Proc then return false unless instance_exec(object, options, &if_condition) + when Proc then return false unless instance_exec(select_target_object(exposure_options), options, &if_condition) when Symbol then return false unless options[if_condition] end end @@ -501,7 +516,7 @@ def conditions_met?(exposure_options, options) unless_conditions.each do |unless_condition| case unless_condition when Hash then unless_condition.each_pair { |k, v| return false if options[k.to_sym] == v } - when Proc then return false if instance_exec(object, options, &unless_condition) + when Proc then return false if instance_exec(select_target_object(exposure_options), options, &unless_condition) when Symbol then return false if options[unless_condition] end end From f1df55c3af5c4d410356225ad9824e60fbedfcd4 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 21:59:15 -0500 Subject: [PATCH 05/13] Throw AttributeNotFoundError exception if no target object found --- lib/grape_entity/entity.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 975e7e84..c26a8ea4 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -1,6 +1,16 @@ require 'multi_json' module Grape + # The AttributeNotFoundError class indicates that an attribute defined + # by an exposure was not found on the target object of an entity. + class AttributeNotFoundError < StandardError + attr_reader :attribute + + def initialize(message, attribute) + super(message) + @attribute = attribute.to_sym + end + end # An Entity is a lightweight structure that allows you to easily # represent data from your application in a consistent and abstracted # way in your API. Entities can also provide documentation for the @@ -475,7 +485,7 @@ def delegate_attribute(attribute, options = {}) elsif target_object.respond_to?(:[], true) target_object.send(:[], attribute) else - raise ArgumentError, ":attribute was unable to be found anywhere" + raise AttributeNotFoundError.new(attribute.to_s, attribute) end end From 4d363acd63867ea86b5a89e02c7e0dd71b5d01f3 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:00:34 -0500 Subject: [PATCH 06/13] Make sure :object is updated when evaluating :if or :unless conditions --- lib/grape_entity/entity.rb | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index c26a8ea4..c7fa585e 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -198,17 +198,26 @@ def self.merge_with(*entity_classes, &block) next if except_attributes.include?(attribute) next if only_attributes.any? && !only_attributes.include?(attribute) - options = expose_options.dup.merge(merge_options) + original_options = original_options.dup + exposure_options = original_options.merge(merge_options) [:if, :unless].each do |condition| - if merge_options.has_key?(condition) && expose_options.has_key?(condition) - options[condition] = Proc.new{|object, instance_options| conditions_met?(merge_options, instance_options) && conditions_met?(expose_options, instance_options)} + if merge_options.has_key?(condition) && original_options.has_key?(condition) + + # only overwrite original_options[:object] if a new object is specified + if merge_options.has_key? :object + original_options[:object] = merge_options[:object] + end + + exposure_options[condition] = Proc.new{|object, instance_options| + conditions_met?(original_options, instance_options) && + conditions_met?(merge_options, instance_options) + } end end - expose :"#{prefix}#{attribute}#{suffix}", options + expose :"#{prefix}#{attribute}#{suffix}", exposure_options end - end end From cd1678426fe31f4da429915dd5da69a0769180e6 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:01:01 -0500 Subject: [PATCH 07/13] Flatten :except and :only arrays --- lib/grape_entity/entity.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index c7fa585e..34d4c387 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -182,20 +182,20 @@ def self.with_options(options) # end def self.merge_with(*entity_classes, &block) merge_options = entity_classes.last.is_a?(Hash) ? entity_classes.pop.dup : {} - except_attributes = [merge_options.delete(:except)].compact - only_attributes = [merge_options.delete(:only)].compact + except_attributes = [merge_options.delete(:except)].flatten.compact + only_attributes = [merge_options.delete(:only)].flatten.compact prefix = merge_options.delete(:prefix) suffix = merge_options.delete(:suffix) merge_options[:object] = block if block_given? entity_classes.each do |entity_class| - raise ArgumentError, "#{entity_class} must be a Grape::Entity" unless entity_class < Grape::Entity + raise ArgumentError, "#{entity_class} must be a Grape::Entity" unless entity_class < Entity merged_entities[entity_class] = merge_options - entity_class.exposures.each_pair do |attribute, expose_options| - next if except_attributes.include?(attribute) + entity_class.exposures.each_pair do |attribute, original_options| + next if except_attributes.any? && except_attributes.include?(attribute) next if only_attributes.any? && !only_attributes.include?(attribute) original_options = original_options.dup From 6ef992dcc60e49af179e76a65d02f6c60fc730e7 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:01:30 -0500 Subject: [PATCH 08/13] Added documentation for Entity.merge_with --- lib/grape_entity/entity.rb | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 34d4c387..b594b90d 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -132,6 +132,9 @@ def entity(options = {}) # block to the expose call to achieve the same effect. # @option options :documentation Define documenation for an exposed # field, typically the value is a hash with two fields, type and desc. + # @option options [Symbol, Proc] :object Specifies the target object to get + # an attribute value from. A [Symbol] references a method on the [#object]. + # A [Proc] should return an alternate object. def self.expose(*args, &block) options = merge_options(args.last.is_a?(Hash) ? args.pop : {}) @@ -167,16 +170,48 @@ def self.with_options(options) # Merge exposures from another entity into the current entity # as a way to "flatten" multiple models for use in formats such as "CSV". # + # @overload merge_with(*entity_classes, &block) + # @param entity_classes [Entity] list of entities to copy exposures from + # (The last parameter can be a [Hash] with options) + # @param block [Proc] A block that returns the target object to retrieve attribute + # values from. + # + # @overload merge_with(*entity_classes, options, &block) + # @param entity_classes [Entity] list of entities to copy exposures from + # (The last parameter can be a [Hash] with options) + # @param options [Hash] Options merged into each exposure that is copied from + # the specified entities. Some additional options determine how exposures are + # copied. + # @see expose + # @param block [Proc] A block that returns the target object to retrieve attribute + # values from. Stored in the [expose] :object option. + # @option options [Symbol, Array] :except Attributes to skip when copying exposures + # @option options [Symbol, Array] :only Attributes to include when copying exposures + # @option options [String] :prefix String to prefix attributes with + # @option options [String] :suffix String to suffix attributes with + # @option options :if Criteria that are evaluated to determine if an exposure + # should be represented. If a copied exposure already has the :if option specified, + # a [Proc] is created that wraps both :if conditions. + # @see expose Check out the description of the default :if option + # @option options :unless Criteria that are evaluated to determine if an exposure + # should be represented. If a copied exposure already has the :unless option specified, + # a [Proc] is created that wraps both :unless conditions. + # @see expose Check out the description of the default :unless option + # @param block [Proc] A block that returns the target object to retrieve attribute + # values from. + # + # @raise ArgumentError Entity classes must inherit from [Entity] + # # @example Merge child entity into parent # # class Address < Grape::Entity - # expose :street, :city, :state, :zip + # expose :id, :street, :city, :state, :zip # end # # class Contact < Grape::Entity - # expose :name + # expose :id, :name # expose :addresses, using: Address, unless: { format: :csv } - # merge_with Address, if: { format: :csv } do + # merge_with Address, if: { format: :csv }, except: :id do # object.addresses.first # end # end From 06137bfd2743e637dd7cf043dd703ca6f8c4d6e1 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:01:45 -0500 Subject: [PATCH 09/13] Added tests for Entity.merge_with --- spec/grape_entity/entity_spec.rb | 248 +++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index f7e3728f..d8d56254 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -267,6 +267,254 @@ class BogusEntity < Grape::Entity subject.exposures[:awesome_thing].should == { documentation: { desc: 'Other description.' } } end end + + describe '.merge_with' do + let(:contacts) do + (1..3).map do |c| + OpenStruct.new( + id: "contact#{c}", + name: "Contact Name #{c}", + addresses: (1..3).map do |a| + OpenStruct.new( + id: "address#{a}", + street: "#{a} Main St.", + city: ["Boston", "New York", "Seattle"][a - 1], + state: ["MA", "NY", "WA"][a - 1], + zip: "1000#{a}" + ) + end + ) + end + end + + it "copies another entity's exposures" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + address_exposures = address_entity.exposures.keys + contact_exposures = contact_entity.exposures.keys + + contact_entity.merge_with address_entity do + object.addresses.last + end + + merged_exposures = contact_entity.exposures.keys + + merged_exposures.sort.should == (address_exposures + contact_exposures).uniq.sort + end + + it "doesn't affect the merged entities exposures" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + address_exposures = address_entity.exposures.keys + contact_exposures = contact_entity.exposures.keys + + contact_entity.merge_with address_entity do + object.addresses.last + end + + address_exposures.should == address_entity.exposures.keys + end + + it "copies exposures from multiple entities" do + email_entity = Class.new(Grape::Entity) + email_entity.expose :id, :label, :email + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + email_exposures = email_entity.exposures.keys + address_exposures = address_entity.exposures.keys + contact_exposures = contact_entity.exposures.keys + + contact_entity.merge_with email_entity, address_entity do + object.addresses.last + end + + merged_exposures = contact_entity.exposures.keys + + merged_exposures.sort.should == (email_exposures + address_exposures + contact_exposures).uniq.sort + end + + it "excludes specified exposures" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + merge_options = { + except: [:state, :zip] + } + + contact_entity.merge_with address_entity, merge_options do + object.addresses.last + end + + merged_exposures = contact_entity.exposures.keys + + merged_exposures.should include(:street, :city) + merged_exposures.should_not include(:state, :zip) + end + + it "only includes specified exposures" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + merge_options = { + only: [:state, :zip] + } + + contact_entity.merge_with address_entity, merge_options do + object.addresses.last + end + + merged_exposures = contact_entity.exposures.keys + + merged_exposures.should_not include(:street, :city) + merged_exposures.should include(:state, :zip) + end + + it "adds a prefix to each attribute" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + merge_options = { + prefix: "prefix_" + } + + contact_entity.merge_with address_entity, merge_options do + object.addresses.last + end + + merged_exposures = contact_entity.exposures.keys + + merged_exposures.should_not include(:street, :city, :state, :zip) + merged_exposures.should include(:prefix_id, :prefix_street, :prefix_city, :prefix_state, :prefix_zip) + end + + it "adds a suffix to each attribute" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state, :zip + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + merge_options = { + suffix: "_suffix" + } + + contact_entity.merge_with address_entity, merge_options do + object.addresses.last + end + + merged_exposures = contact_entity.exposures.keys + + merged_exposures.should_not include(:street, :city, :state, :zip) + merged_exposures.should include(:id_suffix, :street_suffix, :city_suffix, :state_suffix, :zip_suffix) + end + + it "evaluates the :if option as well as the copied exposure's :if option" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state + address_entity.expose :zip, if: Proc.new{|object| object.zip != "10003" } + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + merge_options = { + if: { format: :csv }, + except: :id + } + + contact_entity.merge_with address_entity, merge_options do + object.addresses.last + end + + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash + + entity[:addresses][0].should have_key(:zip) + entity[:addresses][1].should have_key(:zip) + entity[:addresses][2].should_not have_key(:zip) + entity.should_not have_key(:zip) + + contact_entity.merge_with address_entity, merge_options do + object.addresses.first + end + + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash + + entity[:addresses][0].should have_key(:zip) + entity[:addresses][1].should have_key(:zip) + entity[:addresses][2].should_not have_key(:zip) + entity.should have_key(:zip) + + entity = contact_entity.new(contacts[0], format: :json).serializable_hash + + entity[:addresses][0].should have_key(:zip) + entity[:addresses][1].should have_key(:zip) + entity[:addresses][2].should_not have_key(:zip) + entity.should_not have_key(:zip) + end + + it "evaluates the :unless option as well as the copied exposure's :unless option" do + address_entity = Class.new(Grape::Entity) + address_entity.expose :id, :street, :city, :state + address_entity.expose :zip, unless: Proc.new{|object| object.zip == "10003" } + contact_entity = Class.new(Grape::Entity) + contact_entity.expose :id, :name + contact_entity.expose :addresses, using: address_entity + + merge_options = { + unless: { format: :csv }, + except: :id + } + + contact_entity.merge_with address_entity, merge_options do + object.addresses.last + end + + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash + + entity[:addresses][0].should have_key(:zip) + entity[:addresses][1].should have_key(:zip) + entity[:addresses][2].should_not have_key(:zip) + entity.should have_key(:zip) + + contact_entity.merge_with address_entity, merge_options do + object.addresses.first + end + + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash + + entity[:addresses][0].should have_key(:zip) + entity[:addresses][1].should have_key(:zip) + entity[:addresses][2].should_not have_key(:zip) + entity.should have_key(:zip) + + entity = contact_entity.new(contacts[0], format: :json).serializable_hash + + entity[:addresses][0].should have_key(:zip) + entity[:addresses][1].should have_key(:zip) + entity[:addresses][2].should_not have_key(:zip) + entity.should_not have_key(:zip) + end + end describe '.represent' do it 'returns a single entity if called with one object' do From 1fe11f33bef65dbbb71e9be28e48e7061e5f63ef Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:07:47 -0500 Subject: [PATCH 10/13] Updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b252a915..e90a8598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Next Release * [#28](https://github.com/intridea/grape-entity/pull/28): Look for method on entity before calling it on the object - [@MichaelXavier](https://github.com/MichaelXavier). * [#33](https://github.com/intridea/grape-entity/pull/33): Support proper merging of nested conditionals - [@wyattisimo](https://github.com/wyattisimo). * [#43](https://github.com/intridea/grape-entity/pull/43): Call procs in context of entity instance - [@joelvh](https://github.com/joelvh). +* [#45](https://github.com/intridea/grape-entity/pull/45): Ability to "flatten" nested entities into parent (e.g. for CSV) - [@joelvh](https://github.com/joelvh). * Your contribution here. 0.3.0 (2013-03-29) From 722cc6b3b7f04dff91027759bcbb295b7d8fdc7a Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:17:45 -0500 Subject: [PATCH 11/13] RuboCop compliance... --- lib/grape_entity/entity.rb | 72 ++++++++-------- spec/grape_entity/entity_spec.rb | 138 ++++++++++++++++--------------- 2 files changed, 106 insertions(+), 104 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index b594b90d..98fa882c 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -1,11 +1,11 @@ require 'multi_json' module Grape - # The AttributeNotFoundError class indicates that an attribute defined + # The AttributeNotFoundError class indicates that an attribute defined # by an exposure was not found on the target object of an entity. class AttributeNotFoundError < StandardError attr_reader :attribute - + def initialize(message, attribute) super(message) @attribute = attribute.to_sym @@ -132,8 +132,8 @@ def entity(options = {}) # block to the expose call to achieve the same effect. # @option options :documentation Define documenation for an exposed # field, typically the value is a hash with two fields, type and desc. - # @option options [Symbol, Proc] :object Specifies the target object to get - # an attribute value from. A [Symbol] references a method on the [#object]. + # @option options [Symbol, Proc] :object Specifies the target object to get + # an attribute value from. A [Symbol] references a method on the [#object]. # A [Proc] should return an alternate object. def self.expose(*args, &block) options = merge_options(args.last.is_a?(Hash) ? args.pop : {}) @@ -166,38 +166,38 @@ def self.with_options(options) yield @block_options.pop end - - # Merge exposures from another entity into the current entity + + # Merge exposures from another entity into the current entity # as a way to "flatten" multiple models for use in formats such as "CSV". # # @overload merge_with(*entity_classes, &block) - # @param entity_classes [Entity] list of entities to copy exposures from + # @param entity_classes [Entity] list of entities to copy exposures from # (The last parameter can be a [Hash] with options) - # @param block [Proc] A block that returns the target object to retrieve attribute + # @param block [Proc] A block that returns the target object to retrieve attribute # values from. # # @overload merge_with(*entity_classes, options, &block) - # @param entity_classes [Entity] list of entities to copy exposures from + # @param entity_classes [Entity] list of entities to copy exposures from # (The last parameter can be a [Hash] with options) - # @param options [Hash] Options merged into each exposure that is copied from - # the specified entities. Some additional options determine how exposures are + # @param options [Hash] Options merged into each exposure that is copied from + # the specified entities. Some additional options determine how exposures are # copied. # @see expose - # @param block [Proc] A block that returns the target object to retrieve attribute + # @param block [Proc] A block that returns the target object to retrieve attribute # values from. Stored in the [expose] :object option. # @option options [Symbol, Array] :except Attributes to skip when copying exposures # @option options [Symbol, Array] :only Attributes to include when copying exposures # @option options [String] :prefix String to prefix attributes with # @option options [String] :suffix String to suffix attributes with - # @option options :if Criteria that are evaluated to determine if an exposure - # should be represented. If a copied exposure already has the :if option specified, + # @option options :if Criteria that are evaluated to determine if an exposure + # should be represented. If a copied exposure already has the :if option specified, # a [Proc] is created that wraps both :if conditions. # @see expose Check out the description of the default :if option - # @option options :unless Criteria that are evaluated to determine if an exposure - # should be represented. If a copied exposure already has the :unless option specified, + # @option options :unless Criteria that are evaluated to determine if an exposure + # should be represented. If a copied exposure already has the :unless option specified, # a [Proc] is created that wraps both :unless conditions. # @see expose Check out the description of the default :unless option - # @param block [Proc] A block that returns the target object to retrieve attribute + # @param block [Proc] A block that returns the target object to retrieve attribute # values from. # # @raise ArgumentError Entity classes must inherit from [Entity] @@ -207,7 +207,7 @@ def self.with_options(options) # class Address < Grape::Entity # expose :id, :street, :city, :state, :zip # end - # + # # class Contact < Grape::Entity # expose :id, :name # expose :addresses, using: Address, unless: { format: :csv } @@ -221,41 +221,41 @@ def self.merge_with(*entity_classes, &block) only_attributes = [merge_options.delete(:only)].flatten.compact prefix = merge_options.delete(:prefix) suffix = merge_options.delete(:suffix) - + merge_options[:object] = block if block_given? - + entity_classes.each do |entity_class| raise ArgumentError, "#{entity_class} must be a Grape::Entity" unless entity_class < Entity - + merged_entities[entity_class] = merge_options - + entity_class.exposures.each_pair do |attribute, original_options| next if except_attributes.any? && except_attributes.include?(attribute) next if only_attributes.any? && !only_attributes.include?(attribute) - + original_options = original_options.dup exposure_options = original_options.merge(merge_options) - + [:if, :unless].each do |condition| if merge_options.has_key?(condition) && original_options.has_key?(condition) - + # only overwrite original_options[:object] if a new object is specified if merge_options.has_key? :object original_options[:object] = merge_options[:object] end - - exposure_options[condition] = Proc.new{|object, instance_options| + + exposure_options[condition] = proc { |object, instance_options| conditions_met?(original_options, instance_options) && conditions_met?(merge_options, instance_options) } end end - + expose :"#{prefix}#{attribute}#{suffix}", exposure_options end end end - + def self.merged_entities @merged_entities ||= superclass.respond_to?(:merged_entities) ? superclass.exposures.dup : {} end @@ -509,19 +509,19 @@ def value_for(attribute, options = {}) delegate_attribute(attribute, exposure_options) end end - + # Detects what target object to retrieve the attribute value from. # # @param attribute [Symbol] Name of attribute to get a value from the target object - # @param alternate_object [Symbol, Proc] Specifies a target object to use - # instead of [#object] by referencing a method on the instance with a symbol, - # or evaluating a [Proc] and using the result as the target object. The original + # @param alternate_object [Symbol, Proc] Specifies a target object to use + # instead of [#object] by referencing a method on the instance with a symbol, + # or evaluating a [Proc] and using the result as the target object. The original # [#object] is used if no alternate object is specified. # # @raise [AttributeNotFoundError] def delegate_attribute(attribute, options = {}) target_object = select_target_object(options) - + if respond_to?(attribute, true) send(attribute) elsif target_object.respond_to?(attribute, true) @@ -532,10 +532,10 @@ def delegate_attribute(attribute, options = {}) raise AttributeNotFoundError.new(attribute.to_s, attribute) end end - + def select_target_object(options) alternate_object = options[:object] - + case alternate_object when Symbol send(alternate_object) diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index d8d56254..89b54be1 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -51,23 +51,26 @@ module EntitySpec class SomeObject1 attr_accessor :prop1 - + def initialize @prop1 = "value1" end end - + class BogusEntity < Grape::Entity expose :prop1 end end - subject.expose(:bogus, using: EntitySpec::BogusEntity) { self.object.prop1 = "MODIFIED 2"; self.object } - + subject.expose(:bogus, using: EntitySpec::BogusEntity) { + object.prop1 = "MODIFIED 2" + object + } + object = EntitySpec::SomeObject1.new value = subject.represent(object).send(:value_for, :bogus) value.should be_instance_of EntitySpec::BogusEntity - + prop1 = value.send(:value_for, :prop1) prop1.should == "MODIFIED 2" end @@ -131,21 +134,21 @@ class BogusEntity < Grape::Entity model = { birthday: Time.gm(2012, 2, 27) } subject.new(double(model)).as_json[:birthday].should == '02/27/2012' end - + it 'formats an exposure with a :format_with lambda that returns a value from the entity instance' do object = Hash.new - - subject.expose(:size, format_with: lambda{|value| self.object.class.to_s}) + + subject.expose(:size, format_with: lambda { |value| self.object.class.to_s}) subject.represent(object).send(:value_for, :size).should == object.class.to_s end - + it 'formats an exposure with a :format_with symbol that returns a value from the entity instance' do subject.format_with :size_formatter do |date| self.object.class.to_s end object = Hash.new - + subject.expose(:size, format_with: :size_formatter) subject.represent(object).send(:value_for, :size).should == object.class.to_s end @@ -267,7 +270,7 @@ class BogusEntity < Grape::Entity subject.exposures[:awesome_thing].should == { documentation: { desc: 'Other description.' } } end end - + describe '.merge_with' do let(:contacts) do (1..3).map do |c| @@ -286,43 +289,42 @@ class BogusEntity < Grape::Entity ) end end - + it "copies another entity's exposures" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state, :zip contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + address_exposures = address_entity.exposures.keys contact_exposures = contact_entity.exposures.keys - + contact_entity.merge_with address_entity do object.addresses.last end - + merged_exposures = contact_entity.exposures.keys - + merged_exposures.sort.should == (address_exposures + contact_exposures).uniq.sort end - + it "doesn't affect the merged entities exposures" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state, :zip contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + address_exposures = address_entity.exposures.keys - contact_exposures = contact_entity.exposures.keys - + contact_entity.merge_with address_entity do object.addresses.last end - + address_exposures.should == address_entity.exposures.keys end - + it "copies exposures from multiple entities" do email_entity = Class.new(Grape::Entity) email_entity.expose :id, :label, :email @@ -331,184 +333,184 @@ class BogusEntity < Grape::Entity contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + email_exposures = email_entity.exposures.keys address_exposures = address_entity.exposures.keys contact_exposures = contact_entity.exposures.keys - + contact_entity.merge_with email_entity, address_entity do object.addresses.last end - + merged_exposures = contact_entity.exposures.keys - + merged_exposures.sort.should == (email_exposures + address_exposures + contact_exposures).uniq.sort end - + it "excludes specified exposures" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state, :zip contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + merge_options = { except: [:state, :zip] } - + contact_entity.merge_with address_entity, merge_options do object.addresses.last end - + merged_exposures = contact_entity.exposures.keys - + merged_exposures.should include(:street, :city) merged_exposures.should_not include(:state, :zip) end - + it "only includes specified exposures" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state, :zip contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + merge_options = { only: [:state, :zip] } - + contact_entity.merge_with address_entity, merge_options do object.addresses.last end - + merged_exposures = contact_entity.exposures.keys - + merged_exposures.should_not include(:street, :city) merged_exposures.should include(:state, :zip) end - + it "adds a prefix to each attribute" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state, :zip contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + merge_options = { prefix: "prefix_" } - + contact_entity.merge_with address_entity, merge_options do object.addresses.last end - + merged_exposures = contact_entity.exposures.keys - + merged_exposures.should_not include(:street, :city, :state, :zip) merged_exposures.should include(:prefix_id, :prefix_street, :prefix_city, :prefix_state, :prefix_zip) end - + it "adds a suffix to each attribute" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state, :zip contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + merge_options = { suffix: "_suffix" } - + contact_entity.merge_with address_entity, merge_options do object.addresses.last end - + merged_exposures = contact_entity.exposures.keys - + merged_exposures.should_not include(:street, :city, :state, :zip) merged_exposures.should include(:id_suffix, :street_suffix, :city_suffix, :state_suffix, :zip_suffix) end - + it "evaluates the :if option as well as the copied exposure's :if option" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state - address_entity.expose :zip, if: Proc.new{|object| object.zip != "10003" } + address_entity.expose :zip, if: proc { |object| object.zip != "10003" } contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + merge_options = { if: { format: :csv }, except: :id } - + contact_entity.merge_with address_entity, merge_options do object.addresses.last end - + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash - + entity[:addresses][0].should have_key(:zip) entity[:addresses][1].should have_key(:zip) entity[:addresses][2].should_not have_key(:zip) entity.should_not have_key(:zip) - + contact_entity.merge_with address_entity, merge_options do object.addresses.first end - + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash - + entity[:addresses][0].should have_key(:zip) entity[:addresses][1].should have_key(:zip) entity[:addresses][2].should_not have_key(:zip) entity.should have_key(:zip) - + entity = contact_entity.new(contacts[0], format: :json).serializable_hash - + entity[:addresses][0].should have_key(:zip) entity[:addresses][1].should have_key(:zip) entity[:addresses][2].should_not have_key(:zip) entity.should_not have_key(:zip) end - + it "evaluates the :unless option as well as the copied exposure's :unless option" do address_entity = Class.new(Grape::Entity) address_entity.expose :id, :street, :city, :state - address_entity.expose :zip, unless: Proc.new{|object| object.zip == "10003" } + address_entity.expose :zip, unless: proc { |object| object.zip == "10003" } contact_entity = Class.new(Grape::Entity) contact_entity.expose :id, :name contact_entity.expose :addresses, using: address_entity - + merge_options = { unless: { format: :csv }, except: :id } - + contact_entity.merge_with address_entity, merge_options do object.addresses.last end - + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash - + entity[:addresses][0].should have_key(:zip) entity[:addresses][1].should have_key(:zip) entity[:addresses][2].should_not have_key(:zip) entity.should have_key(:zip) - + contact_entity.merge_with address_entity, merge_options do object.addresses.first end - + entity = contact_entity.new(contacts[0], format: :csv).serializable_hash - + entity[:addresses][0].should have_key(:zip) entity[:addresses][1].should have_key(:zip) entity[:addresses][2].should_not have_key(:zip) entity.should have_key(:zip) - + entity = contact_entity.new(contacts[0], format: :json).serializable_hash - + entity[:addresses][0].should have_key(:zip) entity[:addresses][1].should have_key(:zip) entity[:addresses][2].should_not have_key(:zip) From 9cd31e47914ae9fd34485126de2fde06a0bbec32 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:36:11 -0500 Subject: [PATCH 12/13] Fixed class reference --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 93779382..6557b0da 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ module API expose :digest do |status, options| Digest::MD5.hexdigest status.txt end - expose :replies, using: API::Status, as: :replies - expose :last_reply, using: API::Status do |status, options| + expose :replies, using: API::Entities::Status, as: :replies + expose :last_reply, using: API::Entities::Status do |status, options| status.replies.last end @@ -64,7 +64,7 @@ expose :user_name, :ip Don't derive your model classes from `Grape::Entity`, expose them using a presenter. ```ruby -expose :replies, using: API::Status, as: :replies +expose :replies, using: API::Entities::Status, as: :replies ``` #### Conditional Exposure @@ -123,7 +123,7 @@ end Expose under a different name with `:as`. ```ruby -expose :replies, using: API::Status, as: :replies +expose :replies, using: API::Entities::Status, as: :replies ``` #### Format Before Exposing From 91169262e7c36ad4129c26c4b290380a082c9e0d Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Wed, 8 Jan 2014 22:36:22 -0500 Subject: [PATCH 13/13] Added example for flattening entities --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 6557b0da..8c1f34d1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ This gem adds Entity support to API frameworks, such as [Grape](https://github.c ```ruby module API module Entities + class User < Grape::Entity + expose :id, :name, :email + end + class Status < Grape::Entity format_with(:iso_timestamp) { |dt| dt.iso8601 } @@ -30,6 +34,15 @@ module API expose :created_at expose :updated_at end + + # Expose User if the Status is not being flattened. + expose :user, using: API::Entities::User, unless: { flatten: true } + + # "Flatten" User exposures into the Status entity. + # This will add :user_name and :user_email to the status (skipping :id). + merge_with API::Entities::User, prefix: "user_", except: :id, if: { flatten: true } do + object.user + end end end end