Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to "flatten" nested entities into parent (e.g. for CSV) #45

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -21,15 +25,24 @@ 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

with_options(format_with: :iso_timestamp) do
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
152 changes: 143 additions & 9 deletions lib/grape_entity/entity.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 : {})

Expand Down Expand Up @@ -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<Symbol>] :except Attributes to skip when copying exposures
# @option options [Symbol, Array<Symbol>] :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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading