Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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,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
@@ -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
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
@@ -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<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.
@@ -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
270 changes: 260 additions & 10 deletions spec/grape_entity/entity_spec.rb
Original file line number Diff line number Diff line change
@@ -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)