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) diff --git a/README.md b/README.md index 93779382..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 } @@ -21,8 +25,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 @@ -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 @@ -64,7 +77,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 +136,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 diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index ed2c1ce5..98fa882c 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 @@ -122,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 : {}) @@ -154,6 +167,99 @@ def self.with_options(options) @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". + # + # @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 :id, :street, :city, :state, :zip + # end + # + # class Contact < Grape::Entity + # expose :id, :name + # expose :addresses, using: Address, unless: { format: :csv } + # merge_with Address, if: { format: :csv }, except: :id 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)].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 < 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 { |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 + # 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 # the options that were passed into expose. @@ -388,27 +494,55 @@ 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), 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), &formatters[format_with]) elsif format_with.is_a?(Symbol) - send(format_with, delegate_attribute(attribute)) + send(format_with, delegate_attribute(attribute, exposure_options)) elsif format_with.respond_to? :call - instance_exec(delegate_attribute(attribute), &format_with) + instance_exec(delegate_attribute(attribute, exposure_options), &format_with) end else - delegate_attribute(attribute) + delegate_attribute(attribute, exposure_options) end end - def delegate_attribute(attribute) + # 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) + elsif target_object.respond_to?(attribute, true) + target_object.send(attribute) + elsif target_object.respond_to?(:[], true) + target_object.send(:[], attribute) + else + 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) + when Proc + instance_exec(&alternate_object) else - object.send(attribute) + object end end @@ -425,7 +559,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 @@ -436,7 +570,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 diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index f7e3728f..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 @@ -268,6 +271,253 @@ class BogusEntity < Grape::Entity 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_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 { |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 { |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 subject.represent(Object.new).should be_kind_of(subject)