lib/grape_entity/entity.rb
# frozen_string_literal: true
require 'grape_entity/json'
module Grape
# 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
# fields exposed.
#
# @example Entity Definition
#
# module API
# module Entities
# class User < Grape::Entity
# expose :first_name, :last_name, :screen_name, :location
# expose :field, documentation: { type: "string", desc: "describe the field" }
# expose :latest_status, using: API::Status, as: :status, unless: { collection: true }
# expose :email, if: { type: :full }
# expose :new_attribute, if: { version: 'v2' }
# expose(:name) { |model, options| [model.first_name, model.last_name].join(' ') }
# end
# end
# end
#
# Entities are not independent structures, rather, they create
# **representations** of other Ruby objects using a number of methods
# that are convenient for use in an API. Once you've defined an Entity,
# you can use it in your API like this:
#
# @example Usage in the API Layer
#
# module API
# class Users < Grape::API
# version 'v2'
#
# desc 'User index', { params: API::Entities::User.documentation }
# get '/users' do
# @users = User.all
# type = current_user.admin? ? :full : :default
# present @users, with: API::Entities::User, type: type
# end
# end
# end
class Entity
attr_reader :object, :delegator, :options
# The Entity DSL allows you to mix entity functionality into
# your existing classes.
module DSL
def self.included(base)
base.extend ClassMethods
ancestor_entity_class = base.ancestors.detect { |a| a.entity_class if a.respond_to?(:entity_class) }
base.const_set(:Entity, Class.new(ancestor_entity_class || Grape::Entity)) unless const_defined?(:Entity)
end
module ClassMethods
# Returns the automatically-created entity class for this
# Class.
def entity_class(search_ancestors = true)
klass = const_get(:Entity) if const_defined?(:Entity)
klass ||= ancestors.detect { |a| a.entity_class(false) if a.respond_to?(:entity_class) } if search_ancestors
klass
end
# Call this to make exposures to the entity for this Class.
# Can be called with symbols for the attributes to expose,
# a block that yields the full Entity DSL (See Grape::Entity),
# or both.
#
# @example Symbols only.
#
# class User
# include Grape::Entity::DSL
#
# entity :name, :email
# end
#
# @example Mixed.
#
# class User
# include Grape::Entity::DSL
#
# entity :name, :email do
# expose :latest_status, using: Status::Entity, if: :include_status
# expose :new_attribute, if: { version: 'v2' }
# end
# end
def entity(*exposures, &block)
entity_class.expose(*exposures) if exposures.any?
entity_class.class_eval(&block) if block_given?
entity_class
end
end
# Instantiates an entity version of this object.
def entity(options = {})
self.class.entity_class.new(self, options)
end
end
class << self
def root_exposure
@root_exposure ||= Exposure.new(nil, nesting: true)
end
attr_writer :root_exposure, :formatters
# Returns all formatters that are registered for this and it's ancestors
# @return [Hash] of formatters
def formatters
@formatters ||= {}
end
def hash_access
@hash_access ||= :to_sym
end
def hash_access=(value)
@hash_access =
case value
when :to_s, :str, :string
:to_s
else
:to_sym
end
end
def delegation_opts
@delegation_opts ||= { hash_access: hash_access }
end
end
@formatters = {}
def self.inherited(subclass)
subclass.root_exposure = root_exposure.dup
subclass.formatters = formatters.dup
super
end
# This method is the primary means by which you will declare what attributes
# should be exposed by the entity.
#
# @option options :expose_nil When set to false the associated exposure will not
# be rendered if its value is nil.
#
# @option options :as Declare an alias for the representation of this attribute.
# If a proc is presented it is evaluated in the context of the entity so object
# and the entity methods are available to it.
#
# @example as: a proc or lambda
#
# object = OpenStruct(awesomeness: 'awesome_key', awesome: 'not-my-key', other: 'other-key' )
#
# class MyEntity < Grape::Entity
# expose :awesome, as: proc { object.awesomeness }
# expose :awesomeness, as: ->(object, opts) { object.other }
# end
#
# => { 'awesome_key': 'not-my-key', 'other-key': 'awesome_key' }
#
# Note the parameters passed in via the lambda syntax.
#
# @option options :if When passed a Hash, the attribute will only be exposed if the
# runtime options match all the conditions passed in. When passed a lambda, the
# lambda will execute with two arguments: the object being represented and the
# options passed into the representation call. Return true if you want the attribute
# to be exposed.
# @option options :unless When passed a Hash, the attribute will be exposed if the
# runtime options fail to match any of the conditions passed in. If passed a lambda,
# it will yield the object being represented and the options passed to the
# representation call. Return true to prevent exposure, false to allow it.
# @option options :using This option allows you to map an attribute to another Grape
# Entity. Pass it a Grape::Entity class and the attribute in question will
# automatically be transformed into a representation that will receive the same
# options as the parent entity when called. Note that arrays are fine here and
# will automatically be detected and handled appropriately.
# @option options :proc If you pass a Proc into this option, it will
# be used directly to determine the value for that attribute. It
# will be called with the represented object as well as the
# runtime options that were passed in. You can also just supply a
# 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 :merge This option allows you to merge an exposed field to the root
#
# rubocop:disable Layout/LineLength
def self.expose(*args, &block)
options = merge_options(args.last.is_a?(Hash) ? args.pop : {})
if args.size > 1
raise ArgumentError, 'You may not use the :as option on multi-attribute exposures.' if options[:as]
raise ArgumentError, 'You may not use the :expose_nil on multi-attribute exposures.' if options.key?(:expose_nil)
raise ArgumentError, 'You may not use block-setting on multi-attribute exposures.' if block_given?
end
if block_given?
if options[:format_with].respond_to?(:call)
raise ArgumentError, 'You may not use block-setting when also using format_with'
end
if block.parameters.any?
options[:proc] = block
else
options[:nesting] = true
end
end
@documentation = nil
@nesting_stack ||= []
args.each { |attribute| build_exposure_for_attribute(attribute, @nesting_stack, options, block) }
end
# rubocop:enable Layout/LineLength
def self.build_exposure_for_attribute(attribute, nesting_stack, options, block)
exposure_list = nesting_stack.empty? ? root_exposures : nesting_stack.last.nested_exposures
exposure = Exposure.new(attribute, options)
exposure_list.delete_by(attribute) if exposure.override?
exposure_list << exposure
# Nested exposures are given in a block with no parameters.
return unless exposure.nesting?
nesting_stack << exposure
block.call
nesting_stack.pop
end
# Returns exposures that have been declared for this Entity on the top level.
# @return [Array] of exposures
def self.root_exposures
root_exposure.nested_exposures
end
def self.find_exposure(attribute)
root_exposures.find_by(attribute)
end
def self.unexpose(*attributes)
cannot_unexpose! unless can_unexpose?
@documentation = nil
root_exposures.delete_by(*attributes)
end
def self.unexpose_all
cannot_unexpose! unless can_unexpose?
@documentation = nil
root_exposures.clear
end
def self.can_unexpose?
(@nesting_stack ||= []).empty?
end
def self.cannot_unexpose!
raise "You cannot call 'unexpose` inside of nesting exposure!"
end
# Set options that will be applied to any exposures declared inside the block.
#
# @example Multi-exposure if
#
# class MyEntity < Grape::Entity
# with_options if: { awesome: true } do
# expose :awesome, :sweet
# end
# end
def self.with_options(options)
(@block_options ||= []).push(valid_options(options))
yield
@block_options.pop
end
# Returns a hash, the keys are symbolized references to fields in the entity,
# the values are document keys in the entity's documentation key. When calling
# #docmentation, any exposure without a documentation key will be ignored.
def self.documentation
@documentation ||= root_exposures.each_with_object({}) do |exposure, memo|
memo[exposure.key] = exposure.documentation if exposure.documentation && !exposure.documentation.empty?
end
end
# This allows you to declare a Proc in which exposures can be formatted with.
# It take a block with an arity of 1 which is passed as the value of the exposed attribute.
#
# @param name [Symbol] the name of the formatter
# @param block [Proc] the block that will interpret the exposed attribute
#
# @example Formatter declaration
#
# module API
# module Entities
# class User < Grape::Entity
# format_with :timestamp do |date|
# date.strftime('%m/%d/%Y')
# end
#
# expose :birthday, :last_signed_in, format_with: :timestamp
# end
# end
# end
#
# @example Formatters are available to all decendants
#
# Grape::Entity.format_with :timestamp do |date|
# date.strftime('%m/%d/%Y')
# end
#
def self.format_with(name, &block)
raise ArgumentError, 'You must pass a block for formatters' unless block_given?
formatters[name.to_sym] = block
end
# This allows you to set a root element name for your representation.
#
# @param plural [String] the root key to use when representing
# a collection of objects. If missing or nil, no root key will be used
# when representing collections of objects.
# @param singular [String] the root key to use when representing
# a single object. If missing or nil, no root key will be used when
# representing an individual object.
#
# @example Entity Definition
#
# module API
# module Entities
# class User < Grape::Entity
# root 'users', 'user'
# expose :id
# end
# end
# end
#
# @example Usage in the API Layer
#
# module API
# class Users < Grape::API
# version 'v2'
#
# # this will render { "users" : [ { "id" : "1" }, { "id" : "2" } ] }
# get '/users' do
# @users = User.all
# present @users, with: API::Entities::User
# end
#
# # this will render { "user" : { "id" : "1" } }
# get '/users/:id' do
# @user = User.find(params[:id])
# present @user, with: API::Entities::User
# end
# end
# end
def self.root(plural, singular = nil)
@collection_root = plural
@root = singular
end
# This allows you to present a collection of objects.
#
# @param present_collection [true or false] when true all objects will be available as
# items in your presenter instead of wrapping each object in an instance of your presenter.
# When false (default) every object in a collection to present will be wrapped separately
# into an instance of your presenter.
# @param collection_name [Symbol] the name of the collection accessor in your entity object.
# Default :items
#
# @example Entity Definition
#
# module API
# module Entities
# class User < Grape::Entity
# expose :id
# end
#
# class Users < Grape::Entity
# present_collection true
# expose :items, as: 'users', using: API::Entities::User
# expose :version, documentation: { type: 'string',
# desc: 'actual api version',
# required: true }
#
# def version
# options[:version]
# end
# end
# end
# end
#
# @example Usage in the API Layer
#
# module API
# class Users < Grape::API
# version 'v2'
#
# # this will render { "users" : [ { "id" : "1" }, { "id" : "2" } ], "version" : "v2" }
# get '/users' do
# @users = User.all
# present @users, with: API::Entities::Users
# end
#
# # this will render { "user" : { "id" : "1" } }
# get '/users/:id' do
# @user = User.find(params[:id])
# present @user, with: API::Entities::User
# end
# end
# end
#
def self.present_collection(present_collection = false, collection_name = :items)
@present_collection = present_collection
@collection_name = collection_name
end
# This convenience method allows you to instantiate one or more entities by
# passing either a singular or collection of objects. Each object will be
# initialized with the same options. If an array of objects is passed in,
# an array of entities will be returned. If a single object is passed in,
# a single entity will be returned.
#
# @param objects [Object or Array] One or more objects to be represented.
# @param options [Hash] Options that will be passed through to each entity
# representation.
#
# @option options :root [String or false] override the default root name set for the entity.
# Pass nil or false to represent the object or objects with no root name
# even if one is defined for the entity.
# @option options :serializable [true or false] when true a serializable Hash will be returned
#
# @option options :only [Array] all the fields that should be returned
# @option options :except [Array] all the fields that should not be returned
def self.represent(objects, options = {})
@present_collection ||= nil
if objects.respond_to?(:to_ary) && !@present_collection
root_element = root_element(:collection_root)
inner = objects.to_ary.map { |object| new(object, options.reverse_merge(collection: true)).presented }
else
objects = { @collection_name => objects } if @present_collection
root_element = root_element(:root)
inner = new(objects, options).presented
end
root_element = options[:root] if options.key?(:root)
root_element ? { root_element => inner } : inner
end
# This method returns the entity's root or collection root node, or its parent's
# @param root_type: either :collection_root or just :root
def self.root_element(root_type)
instance_variable = "@#{root_type}"
if instance_variable_defined?(instance_variable) && instance_variable_get(instance_variable)
instance_variable_get(instance_variable)
elsif superclass.respond_to? :root_element
superclass.root_element(root_type)
end
end
def presented
if options[:serializable]
serializable_hash
else
self
end
end
# Prevent default serialization of :options or :delegator.
def inspect
fields = serializable_hash.map { |k, v| "#{k}=#{v}" }
"#<#{self.class.name}:#{object_id} #{fields.join(' ')}>"
end
def initialize(object, options = {})
@object = object
@options = options.is_a?(Options) ? options : Options.new(options)
@delegator = Delegator.new(object)
end
def root_exposures
self.class.root_exposures
end
def root_exposure
self.class.root_exposure
end
def documentation
self.class.documentation
end
def formatters
self.class.formatters
end
# The serializable hash is the Entity's primary output. It is the transformed
# hash for the given data model and is used as the basis for serialization to
# JSON and other formats.
#
# @param runtime_options [Hash] Any options you pass in here will be known to the entity
# representation, this is where you can trigger things from conditional options
# etc.
def serializable_hash(runtime_options = {})
return nil if object.nil?
opts = options.merge(runtime_options || {})
root_exposure.serializable_value(self, opts)
end
def exec_with_object(options, &block)
if block.parameters.count == 1
instance_exec(object, &block)
else
instance_exec(object, options, &block)
end
rescue StandardError => e
# it handles: https://github.com/ruby/ruby/blob/v3_0_0_preview1/NEWS.md#language-changes point 3, Proc
# accounting for expose :foo, &:bar
if e.is_a?(ArgumentError) && block.parameters == [[:req], [:rest]]
raise Grape::Entity::Deprecated.new e.message, 'in ruby 3.0'
end
raise e
end
def exec_with_attribute(attribute, &block)
instance_exec(delegate_attribute(attribute), &block)
end
def value_for(key, options = Options.new)
root_exposure.valid_value_for(key, self, options)
end
def delegate_attribute(attribute)
if is_defined_in_entity?(attribute)
send(attribute)
elsif delegator.accepts_options?
delegator.delegate(attribute, **self.class.delegation_opts)
else
delegator.delegate(attribute)
end
end
def is_defined_in_entity?(attribute)
return false unless respond_to?(attribute, true)
ancestors = self.class.ancestors
ancestors.index(Grape::Entity) > ancestors.index(method(attribute).owner)
end
alias as_json serializable_hash
def to_json(options = {})
options = options.to_h if options&.respond_to?(:to_h)
Grape::Entity::Json.dump(serializable_hash(options))
end
def to_xml(options = {})
options = options.to_h if options&.respond_to?(:to_h)
serializable_hash(options).to_xml(options)
end
# All supported options.
OPTIONS = %i[
rewrite
as
if
unless
using
with
proc
documentation
format_with
safe
attr_path
if_extras
unless_extras
merge
expose_nil
override
default
].to_set.freeze
# Merges the given options with current block options.
#
# @param options [Hash] Exposure options.
def self.merge_options(options)
opts = {}
merge_logic = proc do |key, existing_val, new_val|
if %i[if unless].include?(key)
if existing_val.is_a?(Hash) && new_val.is_a?(Hash)
existing_val.merge(new_val)
elsif new_val.is_a?(Hash)
(opts[:"#{key}_extras"] ||= []) << existing_val
new_val
else
(opts[:"#{key}_extras"] ||= []) << new_val
existing_val
end
else
new_val
end
end
@block_options ||= []
opts.merge @block_options.inject({}) { |final, step|
final.merge(step, &merge_logic)
}.merge(valid_options(options), &merge_logic)
end
# Raises an error if the given options include unknown keys.
# Renames aliased options.
#
# @param options [Hash] Exposure options.
def self.valid_options(options)
options.each_key do |key|
raise ArgumentError, "#{key.inspect} is not a valid option." unless OPTIONS.include?(key)
end
options[:using] = options.delete(:with) if options.key?(:with)
options
end
end
end