ruby-grape/grape-entity

View on GitHub
lib/grape_entity/entity.rb

Summary

Maintainability
D
1 day
Test Coverage
# 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