Skip to content

Commit 25a92c1

Browse files
author
Michael Bleigh
committed
Adds Entity class for lightweight representation building.
1 parent 83762d0 commit 25a92c1

File tree

3 files changed

+310
-4
lines changed

3 files changed

+310
-4
lines changed

lib/grape.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
require 'rack/builder'
33

44
module Grape
5-
autoload :API, 'grape/api'
6-
autoload :Endpoint, 'grape/endpoint'
5+
autoload :API, 'grape/api'
6+
autoload :Endpoint, 'grape/endpoint'
77
autoload :MiddlewareStack, 'grape/middleware_stack'
8-
autoload :Client, 'grape/client'
9-
autoload :Route, 'grape/route'
8+
autoload :Client, 'grape/client'
9+
autoload :Route, 'grape/route'
10+
autoload :Entity, 'grape/entity'
1011

1112
module Middleware
1213
autoload :Base, 'grape/middleware/base'

lib/grape/entity.rb

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
require 'hashie'
2+
3+
module Grape
4+
# An Entity is a lightweight structure that allows you to easily
5+
# represent data from your application in a consistent and abstracted
6+
# way in your API.
7+
#
8+
# @example Entity Definition
9+
#
10+
# module API
11+
# module Entities
12+
# class User < Grape::Endpoint
13+
# expose :name, :screen_name, :location
14+
# expose :latest_status, :using => API::Status, :as => :status, :unless => {:collection => true}
15+
# expose :email, :if => {:type => :full}
16+
# expose :new_attribute, :if => {:version => 'v2'}
17+
# end
18+
# end
19+
# end
20+
#
21+
# Entities are not independent structures, rather, they create
22+
# **representations** of other Ruby objects using a number of methods
23+
# that are convenient for use in an API. Once you've defined an Entity,
24+
# you can use it in your API like this:
25+
#
26+
# @example Usage in the API Layer
27+
#
28+
# module API
29+
# class Users < Grape::API
30+
# version 'v2'
31+
#
32+
# get '/users' do
33+
# @users = User.all
34+
# type = current_user.admin? ? :full : :default
35+
# present @users, :with => API::Entities::User, :type => type
36+
# end
37+
# end
38+
# end
39+
class Entity
40+
attr_reader :object, :options
41+
42+
# This method is the primary means by which you will declare what attributes
43+
# should be exposed by the entity.
44+
#
45+
# @option options :as Declare an alias for the representation of this attribute.
46+
# @option options :if When passed a Hash, the attribute will only be exposed if the
47+
# runtime options match all the conditions passed in. When passed a lambda, the
48+
# lambda will execute with two arguments: the object being represented and the
49+
# options passed into the representation call. Return true if you want the attribute
50+
# to be exposed.
51+
# @option options :unless When passed a Hash, the attribute will be exposed if the
52+
# runtime options fail to match any of the conditions passed in. If passed a lambda,
53+
# it will yield the object being represented and the options passed to the
54+
# representation call. Return true to prevent exposure, false to allow it.
55+
# @option options :using This option allows you to map an attribute to another Grape
56+
# Entity. Pass it a Grape::Entity class and the attribute in question will
57+
# automatically be transformed into a representation that will receive the same
58+
# options as the parent entity when called. Note that arrays are fine here and
59+
# will automatically be detected and handled appropriately.
60+
def self.expose(*args)
61+
options = args.last.is_a?(Hash) ? args.pop : {}
62+
63+
if options[:as] && args.size > 1
64+
raise ArgumentError, "You may not use the :as option on multi-attribute exposures."
65+
end
66+
67+
args.each do |attribute|
68+
exposures[attribute.to_sym] = options
69+
end
70+
end
71+
72+
# Returns a hash of exposures that have been declared for this Entity. The keys
73+
# are symbolized references to methods on the containing object, the values are
74+
# the options that were passed into expose.
75+
def self.exposures
76+
(@exposures ||= {})
77+
end
78+
79+
# This convenience method allows you to instantiate one or more entities by
80+
# passing either a singular or collection of objects. Each object will be
81+
# initialized with the same options. If an array of objects is passed in,
82+
# an array of entities will be returned. If a single object is passed in,
83+
# a single entity will be returned.
84+
#
85+
# @param objects [Object or Array] One or more objects to be represented.
86+
# @param options [Hash] Options that will be passed through to each entity
87+
# representation.
88+
def self.represent(objects, options = {})
89+
if objects.is_a?(Array)
90+
objects.map{|o| self.new(o, {:collection => true}.merge(options))}
91+
else
92+
self.new(objects, options)
93+
end
94+
end
95+
96+
def initialize(object, options = {})
97+
@object, @options = object, options
98+
end
99+
100+
def exposures
101+
self.class.exposures
102+
end
103+
104+
# The serializable hash is the Entity's primary output. It is the transformed
105+
# hash for the given data model and is used as the basis for serialization to
106+
# JSON and other formats.
107+
#
108+
# @param options [Hash] Any options you pass in here will be known to the entity
109+
# representation, this is where you can trigger things from conditional options
110+
# etc.
111+
def serializable_hash(options = {})
112+
exposures.inject({}) do |output, (attribute, exposure_options)|
113+
output[key_for(attribute)] = value_for(attribute) if conditions_met?(exposure_options, options)
114+
output
115+
end
116+
end
117+
118+
alias :as_json :serializable_hash
119+
120+
protected
121+
122+
def key_for(attribute)
123+
exposures[attribute.to_sym][:as] || attribute.to_sym
124+
end
125+
126+
def value_for(attribute)
127+
exposure_options = exposures[attribute.to_sym]
128+
129+
if exposure_options[:using]
130+
exposure_options[:using].represent(object.send(attribute))
131+
else
132+
object.send(attribute)
133+
end
134+
end
135+
136+
def conditions_met?(exposure_options, options)
137+
if_condition = exposure_options[:if]
138+
unless_condition = exposure_options[:unless]
139+
140+
case if_condition
141+
when Hash; if_condition.each_pair{|k,v| return false if options[k.to_sym] != v }
142+
when Proc; return false unless if_condition.call(object, options)
143+
end
144+
145+
case unless_condition
146+
when Hash; unless_condition.each_pair{|k,v| return false if options[k.to_sym] == v}
147+
when Proc; return false if unless_condition.call(object, options)
148+
end
149+
150+
true
151+
end
152+
end
153+
end

spec/grape/entity_spec.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
require 'spec_helper'
2+
3+
describe Grape::Entity do
4+
let(:fresh_class){ Class.new(Grape::Entity) }
5+
6+
context 'class methods' do
7+
subject{ fresh_class }
8+
9+
describe '.expose' do
10+
context 'multiple attributes' do
11+
it 'should be able to add multiple exposed attributes with a single call' do
12+
subject.expose :name, :email, :location
13+
subject.exposures.size.should == 3
14+
end
15+
16+
it 'should set the same options for all exposures passed' do
17+
subject.expose :name, :email, :location, :foo => :bar
18+
subject.exposures.values.each{|v| v.should == {:foo => :bar}}
19+
end
20+
end
21+
22+
context 'option validation' do
23+
it 'should make sure that :as only works on single attribute calls' do
24+
expect{ subject.expose :name, :email, :as => :foo }.to raise_error(ArgumentError)
25+
expect{ subject.expose :name, :as => :foo }.not_to raise_error
26+
end
27+
end
28+
end
29+
30+
describe '.represent' do
31+
it 'should return a single entity if called with one object' do
32+
subject.represent(Object.new).should be_kind_of(subject)
33+
end
34+
35+
it 'should return multiple entities if called with a collection' do
36+
representation = subject.represent(4.times.map{Object.new})
37+
representation.should be_kind_of(Array)
38+
representation.size.should == 4
39+
representation.reject{|r| r.kind_of?(subject)}.should be_empty
40+
end
41+
42+
it 'should add the :collection => true option if called with a collection' do
43+
representation = subject.represent(4.times.map{Object.new})
44+
representation.each{|r| r.options[:collection].should be_true}
45+
end
46+
end
47+
48+
describe '#initialize' do
49+
it 'should take an object and an optional options hash' do
50+
expect{ subject.new(Object.new) }.not_to raise_error
51+
expect{ subject.new }.to raise_error(ArgumentError)
52+
expect{ subject.new(Object.new, {}) }.not_to raise_error
53+
end
54+
55+
it 'should have attribute readers for the object and options' do
56+
entity = subject.new('abc', {})
57+
entity.object.should == 'abc'
58+
entity.options.should == {}
59+
end
60+
end
61+
end
62+
63+
context 'instance methods' do
64+
let(:model){ mock(attributes) }
65+
let(:attributes){ {
66+
:name => 'Bob Bobson',
67+
:email => '[email protected]',
68+
:friends => [
69+
mock(:name => "Friend 1", :email => '[email protected]', :friends => []),
70+
mock(:name => "Friend 2", :email => '[email protected]', :friends => [])
71+
]
72+
} }
73+
subject{ fresh_class.new(model) }
74+
75+
describe '#serializable_hash' do
76+
end
77+
78+
describe '#value_for' do
79+
before do
80+
fresh_class.class_eval do
81+
expose :name, :email
82+
expose :friends, :using => self
83+
end
84+
end
85+
86+
it 'should pass through bare expose attributes' do
87+
subject.send(:value_for, :name).should == attributes[:name]
88+
end
89+
90+
it 'should instantiate a representation if that is called for' do
91+
rep = subject.send(:value_for, :friends)
92+
rep.reject{|r| r.is_a?(fresh_class)}.should be_empty
93+
rep.first.serializable_hash[:name].should == 'Friend 1'
94+
rep.last.serializable_hash[:name].should == 'Friend 2'
95+
end
96+
end
97+
98+
describe '#key_for' do
99+
it 'should return the attribute if no :as is set' do
100+
fresh_class.expose :name
101+
subject.send(:key_for, :name).should == :name
102+
end
103+
104+
it 'should return a symbolized version of the attribute' do
105+
fresh_class.expose :name
106+
subject.send(:key_for, 'name').should == :name
107+
end
108+
109+
it 'should return the :as alias if one exists' do
110+
fresh_class.expose :name, :as => :nombre
111+
subject.send(:key_for, 'name').should == :nombre
112+
end
113+
end
114+
115+
describe '#conditions_met?' do
116+
it 'should only pass through hash :if exposure if all attributes match' do
117+
exposure_options = {:if => {:condition1 => true, :condition2 => true}}
118+
119+
subject.send(:conditions_met?, exposure_options, {}).should be_false
120+
subject.send(:conditions_met?, exposure_options, :condition1 => true).should be_false
121+
subject.send(:conditions_met?, exposure_options, :condition1 => true, :condition2 => true).should be_true
122+
subject.send(:conditions_met?, exposure_options, :condition1 => false, :condition2 => true).should be_false
123+
subject.send(:conditions_met?, exposure_options, :condition1 => true, :condition2 => true, :other => true).should be_true
124+
end
125+
126+
it 'should only pass through proc :if exposure if it returns truthy value' do
127+
exposure_options = {:if => lambda{|obj,opts| opts[:true]}}
128+
129+
subject.send(:conditions_met?, exposure_options, :true => false).should be_false
130+
subject.send(:conditions_met?, exposure_options, :true => true).should be_true
131+
end
132+
133+
it 'should only pass through hash :unless exposure if any attributes do not match' do
134+
exposure_options = {:unless => {:condition1 => true, :condition2 => true}}
135+
136+
subject.send(:conditions_met?, exposure_options, {}).should be_true
137+
subject.send(:conditions_met?, exposure_options, :condition1 => true).should be_false
138+
subject.send(:conditions_met?, exposure_options, :condition1 => true, :condition2 => true).should be_false
139+
subject.send(:conditions_met?, exposure_options, :condition1 => false, :condition2 => true).should be_false
140+
subject.send(:conditions_met?, exposure_options, :condition1 => true, :condition2 => true, :other => true).should be_false
141+
subject.send(:conditions_met?, exposure_options, :condition1 => false, :condition2 => false).should be_true
142+
end
143+
144+
it 'should only pass through proc :unless exposure if it returns falsy value' do
145+
exposure_options = {:unless => lambda{|object,options| options[:true] == true}}
146+
147+
subject.send(:conditions_met?, exposure_options, :true => false).should be_true
148+
subject.send(:conditions_met?, exposure_options, :true => true).should be_false
149+
end
150+
end
151+
end
152+
end

0 commit comments

Comments
 (0)