22require 'set'
33
44module 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