Skip to content

Commit 8c2735b

Browse files
committed
Manually merged ruby-grape#45 for use until it's in the official repo.
1 parent d904381 commit 8c2735b

File tree

1 file changed

+147
-12
lines changed

1 file changed

+147
-12
lines changed

lib/grape_entity/entity.rb

Lines changed: 147 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22
require 'set'
33

44
module Grape
5+
6+
# The AttributeNotFoundError class indicates that an attribute defined
7+
# by an exposure was not found on the target object of an entity.
8+
class AttributeNotFoundError < StandardError
9+
attr_reader :attribute
10+
11+
def initialize(message, attribute)
12+
super(message)
13+
@attribute = attribute.to_sym
14+
end
15+
end
16+
517
# An Entity is a lightweight structure that allows you to easily
618
# represent data from your application in a consistent and abstracted
719
# way in your API. Entities can also provide documentation for the
@@ -123,6 +135,9 @@ def entity(options = {})
123135
# block to the expose call to achieve the same effect.
124136
# @option options :documentation Define documenation for an exposed
125137
# field, typically the value is a hash with two fields, type and desc.
138+
# @option options [Symbol, Proc] :object Specifies the target object to get
139+
# an attribute value from. A [Symbol] references a method on the [#object].
140+
# A [Proc] should return an alternate object.
126141
def self.expose(*args, &block)
127142
options = merge_options(args.last.is_a?(Hash) ? args.pop : {})
128143

@@ -171,6 +186,99 @@ def self.with_options(options)
171186
@block_options.pop
172187
end
173188

189+
# Merge exposures from another entity into the current entity
190+
# as a way to "flatten" multiple models for use in formats such as "CSV".
191+
#
192+
# @overload merge_with(*entity_classes, &block)
193+
# @param entity_classes [Entity] list of entities to copy exposures from
194+
# (The last parameter can be a [Hash] with options)
195+
# @param block [Proc] A block that returns the target object to retrieve attribute
196+
# values from.
197+
#
198+
# @overload merge_with(*entity_classes, options, &block)
199+
# @param entity_classes [Entity] list of entities to copy exposures from
200+
# (The last parameter can be a [Hash] with options)
201+
# @param options [Hash] Options merged into each exposure that is copied from
202+
# the specified entities. Some additional options determine how exposures are
203+
# copied.
204+
# @see expose
205+
# @param block [Proc] A block that returns the target object to retrieve attribute
206+
# values from. Stored in the [expose] :object option.
207+
# @option options [Symbol, Array<Symbol>] :except Attributes to skip when copying exposures
208+
# @option options [Symbol, Array<Symbol>] :only Attributes to include when copying exposures
209+
# @option options [String] :prefix String to prefix attributes with
210+
# @option options [String] :suffix String to suffix attributes with
211+
# @option options :if Criteria that are evaluated to determine if an exposure
212+
# should be represented. If a copied exposure already has the :if option specified,
213+
# a [Proc] is created that wraps both :if conditions.
214+
# @see expose Check out the description of the default :if option
215+
# @option options :unless Criteria that are evaluated to determine if an exposure
216+
# should be represented. If a copied exposure already has the :unless option specified,
217+
# a [Proc] is created that wraps both :unless conditions.
218+
# @see expose Check out the description of the default :unless option
219+
# @param block [Proc] A block that returns the target object to retrieve attribute
220+
# values from.
221+
#
222+
# @raise ArgumentError Entity classes must inherit from [Entity]
223+
#
224+
# @example Merge child entity into parent
225+
#
226+
# class Address < Grape::Entity
227+
# expose :id, :street, :city, :state, :zip
228+
# end
229+
#
230+
# class Contact < Grape::Entity
231+
# expose :id, :name
232+
# expose :addresses, using: Address, unless: { format: :csv }
233+
# merge_with Address, if: { format: :csv }, except: :id do
234+
# object.addresses.first
235+
# end
236+
# end
237+
def self.merge_with(*entity_classes, &block)
238+
merge_options = entity_classes.last.is_a?(Hash) ? entity_classes.pop.dup : {}
239+
except_attributes = [merge_options.delete(:except)].flatten.compact
240+
only_attributes = [merge_options.delete(:only)].flatten.compact
241+
prefix = merge_options.delete(:prefix)
242+
suffix = merge_options.delete(:suffix)
243+
244+
merge_options[:object] = block if block_given?
245+
246+
entity_classes.each do |entity_class|
247+
raise ArgumentError, "#{entity_class} must be a Grape::Entity" unless entity_class < Entity
248+
249+
merged_entities[entity_class] = merge_options
250+
251+
entity_class.exposures.each_pair do |attribute, original_options|
252+
next if except_attributes.any? && except_attributes.include?(attribute)
253+
next if only_attributes.any? && !only_attributes.include?(attribute)
254+
255+
original_options = original_options.dup
256+
exposure_options = original_options.merge(merge_options)
257+
258+
[:if, :unless].each do |condition|
259+
if merge_options.has_key?(condition) && original_options.has_key?(condition)
260+
261+
# only overwrite original_options[:object] if a new object is specified
262+
if merge_options.has_key? :object
263+
original_options[:object] = merge_options[:object]
264+
end
265+
266+
exposure_options[condition] = proc { |object, instance_options|
267+
conditions_met?(original_options, instance_options) &&
268+
conditions_met?(merge_options, instance_options)
269+
}
270+
end
271+
end
272+
273+
expose :"#{prefix}#{attribute}#{suffix}", exposure_options
274+
end
275+
end
276+
end
277+
278+
def self.merged_entities
279+
@merged_entities ||= superclass.respond_to?(:merged_entities) ? superclass.exposures.dup : {}
280+
end
281+
174282
# Returns a hash of exposures that have been declared for this Entity or ancestors. The keys
175283
# are symbolized references to methods on the containing object, the values are
176284
# the options that were passed into expose.
@@ -430,7 +538,7 @@ def value_for(attribute, options = {})
430538
if exposure_options[:proc]
431539
exposure_options[:using].represent(instance_exec(object, options, &exposure_options[:proc]), using_options)
432540
else
433-
exposure_options[:using].represent(delegate_attribute(attribute), using_options)
541+
exposure_options[:using].represent(delegate_attribute(attribute, exposure_options), using_options)
434542
end
435543

436544
elsif exposure_options[:proc]
@@ -440,11 +548,11 @@ def value_for(attribute, options = {})
440548
format_with = exposure_options[:format_with]
441549

442550
if format_with.is_a?(Symbol) && formatters[format_with]
443-
instance_exec(delegate_attribute(attribute), &formatters[format_with])
551+
instance_exec(delegate_attribute(attribute, exposure_options), &formatters[format_with])
444552
elsif format_with.is_a?(Symbol)
445-
send(format_with, delegate_attribute(attribute))
553+
send(format_with, delegate_attribute(attribute, exposure_options))
446554
elsif format_with.respond_to? :call
447-
instance_exec(delegate_attribute(attribute), &format_with)
555+
instance_exec(delegate_attribute(attribute, exposure_options), &format_with)
448556
end
449557

450558
elsif nested_exposures.any?
@@ -453,16 +561,43 @@ def value_for(attribute, options = {})
453561
end]
454562

455563
else
456-
delegate_attribute(attribute)
564+
delegate_attribute(attribute, exposure_options)
457565
end
458566
end
459567

460-
def delegate_attribute(attribute)
461-
name = self.class.name_for(attribute)
462-
if respond_to?(name, true)
463-
send(name)
568+
# Detects what target object to retrieve the attribute value from.
569+
#
570+
# @param attribute [Symbol] Name of attribute to get a value from the target object
571+
# @param alternate_object [Symbol, Proc] Specifies a target object to use
572+
# instead of [#object] by referencing a method on the instance with a symbol,
573+
# or evaluating a [Proc] and using the result as the target object. The original
574+
# [#object] is used if no alternate object is specified.
575+
#
576+
# @raise [AttributeNotFoundError]
577+
def delegate_attribute(attribute, options = {})
578+
target_object = select_target_object(options)
579+
580+
if respond_to?(attribute, true)
581+
send(attribute)
582+
elsif target_object.respond_to?(attribute, true)
583+
target_object.send(attribute)
584+
elsif target_object.respond_to?(:[], true)
585+
target_object.send(:[], attribute)
464586
else
465-
object.send(name)
587+
raise AttributeNotFoundError.new(attribute.to_s, attribute)
588+
end
589+
end
590+
591+
def select_target_object(options)
592+
alternate_object = options[:object]
593+
594+
case alternate_object
595+
when Symbol
596+
send(alternate_object)
597+
when Proc
598+
instance_exec(&alternate_object)
599+
else
600+
object
466601
end
467602
end
468603

@@ -481,7 +616,7 @@ def conditions_met?(exposure_options, options)
481616
if_conditions.each do |if_condition|
482617
case if_condition
483618
when Hash then if_condition.each_pair { |k, v| return false if options[k.to_sym] != v }
484-
when Proc then return false unless instance_exec(object, options, &if_condition)
619+
when Proc then return false unless instance_exec(select_target_object(exposure_options), options, &if_condition)
485620
when Symbol then return false unless options[if_condition]
486621
end
487622
end
@@ -492,7 +627,7 @@ def conditions_met?(exposure_options, options)
492627
unless_conditions.each do |unless_condition|
493628
case unless_condition
494629
when Hash then unless_condition.each_pair { |k, v| return false if options[k.to_sym] == v }
495-
when Proc then return false if instance_exec(object, options, &unless_condition)
630+
when Proc then return false if instance_exec(select_target_object(exposure_options), options, &unless_condition)
496631
when Symbol then return false if options[unless_condition]
497632
end
498633
end

0 commit comments

Comments
 (0)