nesquena/rabl

View on GitHub
lib/rabl/engine.rb

Summary

Maintainability
C
1 day
Test Coverage
module Rabl
  class Engine
    include Helpers
    include Partials
    include Helpers::Escaper

    # List of supported rendering formats
    FORMATS = [:json, :xml, :plist, :bson, :msgpack]
    SAFE_FORMATS = FORMATS + [:mpac, :dumpable, :hash]

    # Constructs a new ejs engine based on given vars, handler and declarations
    # Rabl::Engine.new("...source...", { :format => "xml", :root => true, :view_path => "/path/to/views" })
    def initialize(source, options = {})
      @_source        = source
      @_settings      = {}
      @_options       = options

      @_view_path     = options[:view_path]
      @_context_scope = options[:scope]

      @_cache_read_on_render = true
    end

    def source=(source)
      @_source = source
    end

    # Renders the representation based on source, object, context_scope and locals
    # Rabl::Engine.new("...source...", { :format => "xml" }).apply(context_scope, { :foo => "bar", :object => @user })
    def apply(context_scope, locals, &block)
      locals = locals.dup unless locals.nil?

      set_instance_variables!(context_scope, locals)

      reset_settings!
      reset_options!

      eval_source(locals, &block)

      instance_exec(root_object, &block) if block_given?

      self
    end

    # Renders the representation based on a previous apply
    # Rabl::Engine.new("...source...", { :format => "xml" }).apply(context_scope, { :foo => "bar", :object => @user }).render
    def render(context_scope = nil, locals = nil, &block)
      apply(context_scope, locals, &block) if context_scope || locals || block

      cache_results do
        send("to_#{@_options[:format]}")
      end
    end

    def cache_key
      return unless defined?(@_cache_key)

      @_full_cache_key ||= begin
        cache_key = Array(@_cache_key) + [@_options[:root_name], @_options[:format]]

        if digestor_available? && respond_to?(:lookup_context) && lookup_context
          template = @_options[:template] || @virtual_path

          digest =
            if Rails.version.to_s =~ /^[67]/
              Digestor.digest(name: template, finder: lookup_context, format: :rabl)
            elsif Gem::Version.new(Rails.version) >= Gem::Version.new('4.1')
              Digestor.digest(:name => template, :finder => lookup_context)
            else
              Digestor.digest(template, :rabl, lookup_context)
            end

          cache_key << digest
        end

        cache_key
      end
    end

    # Returns a hash representation of the data object
    # to_hash(:root => true, :child_root => true)
    def to_hash(options = {})
      options.reverse_merge!(@_options)

      data = root_object

      options[:root_name] = determine_object_root(data, root_name, options[:root])

      result =
        if is_object?(data) || !data # object @user
          Builder.new(data, @_settings, options).to_hash
        elsif is_collection?(data) # collection @users
          MultiBuilder.new(data, @_settings, options).to_a
        end

      result = escape_output(result) if Rabl.configuration.escape_all_output

      result
    end

    def to_dumpable(options = {})
      options.reverse_merge!({ :child_root => Rabl.configuration.include_child_root })

      result = to_hash(options)
      result = { collection_root_name => result } if collection_root_name
      result
    end

    # Returns a json representation of the data object
    # to_json(:root => true)
    def to_json(options = {})
      options.reverse_merge!({ :root => Rabl.configuration.include_json_root })
      result = to_dumpable(options)
      format_json(result)
    end

    # Returns a msgpack representation of the data object
    # to_msgpack(:root => true)
    def to_msgpack(options = {})
      options = { :root => Rabl.configuration.include_msgpack_root }.merge(options)
      result = to_dumpable(options)
      Rabl.configuration.msgpack_engine.pack(result)
    end
    alias_method :to_mpac, :to_msgpack

    # Returns a plist representation of the data object
    # to_plist(:root => true)
    def to_plist(options = {})
      options = { :root => Rabl.configuration.include_plist_root }.merge(options)
      result = to_dumpable(options)
      Rabl.configuration.plist_engine.dump(result)
    end

    # Returns an xml representation of the data object
    # to_xml(:root => true)
    def to_xml(options = {})
      options = {
        :root       => (include_root = Rabl.configuration.include_xml_root),
        :child_root => include_root && Rabl.configuration.include_child_root
      }.merge(options)

      xml_options = Rabl.configuration.default_xml_options.merge(:root => collection_root_name || root_name)

      result = to_hash(options)

      result.to_xml(xml_options)
    end

    # Returns a bson representation of the data object
    # to_bson(:root => true)
    def to_bson(options = {})
      options = { :root => Rabl.configuration.include_bson_root }.merge(options)
      result = to_dumpable(options)

      if !collection_root_name && is_collection?(root_object) && root_object.is_a?(Array)
        result = { root_name => result }
      end

      Rabl.configuration.bson_engine.serialize(result).to_s
    end

    # Sets the object to be used as the data source for this template
    # object(@user)
    # object @user => :person
    # object @users
    def object(template_data)
      current_data  = (@_locals[:object].nil? || template_data == false) ? template_data : @_locals[:object]
      @_data_object = data_object(current_data)
      @_root_name_data = template_data.is_a?(Hash) && !current_data.is_a?(Hash) ? template_data : current_data
      @_root_name_data = @_root_name_data.values.first if @_root_name_data.is_a?(Hash)

      # If we turn this around, `@_root_name_date ==` may trigger data to be loaded unnecessarily.
      # TODO: is nil a different semantic? otherwise don't use `false ==`, use !
      if false == @_root_name_data
        @_object_root_name = false
        @_collection_name = false
      end
    end

    # Returns the current object that is the topic of this template
    # Can be the collection or the object depending on topic assigned
    # root_object => @user
    def root_object
      return @_data_object if defined?(@_data_object)

      data = @_locals[:object].nil? ? default_object : @_locals[:object]
      @_data_object = data_object(data)
    end

    def root_name
      return @_data_name if defined?(@_data_name)

      @_data_name = @_options[:object_root_name] || begin
        data = defined?(@_root_name_data) ? @_root_name_data : (@_locals[:object].nil? ? root_object : @_locals[:object])
        data_name(data)
      end
    end

    # Sets the object as a collection casted to a simple array
    # collection @users
    # collection @users => :people
    # collection @users, :root => :person
    # collection @users, :object_root => :person
    def collection(data, options = {})
      @_collection_name = options[:root] if options[:root]
      @_collection_name ||= data.values.first if data.is_a?(Hash)

      @_object_root_name = options[:object_root] if options.has_key?(:object_root)

      object(data_object(data) || [])
    end

    # Sets the cache key to be used by ActiveSupport::Cache.expand_cache_key
    # cache @user            # calls @user.cache_key
    # cache ['rabl', @user]  # calls @user.cache_key and prefixes with rabl/
    # cache 'user'           # explicit key of 'user'
    # cache                  # uses the current item within a collection
    # cache 'user', expires_in: 1.hour
    # options is passed through to the cache store
    def cache(key = nil, options = {})
      key ||= root_object # if called but missing, use object
      @_cache_key     = key
      @_cache_options = options
    end

    # Indicates an attribute or method should be included in the json output
    # attribute :foo, :as => "bar"
    # attribute :foo => :bar, :bar => :baz
    # attribute :foo => :bar, :bar => :baz, :if => lambda { |r| r.foo }
    def attribute(*args)
      if args.first.is_a?(Hash) # :foo => :bar, :bar => :baz
        attr_aliases  = args.first.except(:if, :unless)
        conditions    = args.first.slice(:if, :unless)

        attr_aliases.each do |key, as|
          attribute(key, conditions.merge(:as => as))
        end
      else # array of attributes i.e :foo, :bar, :baz
        options = args.extract_options!
        args.each do |name|
          @_settings[:attributes] << { :name => name, :options => options }
        end
      end
    end
    alias_method :attributes, :attribute

    # Creates an arbitrary node that is included in the json output.
    # node(:foo) { "bar" }
    # node(:foo, :if => lambda { ... }) { "bar" }
    def node(name = nil, options = {}, &block)
      @_settings[:node] << { :name => name, :options => options, :block => block }
    end
    alias_method :code, :node

    # Creates a child node that is included in json output
    # child(@user) { attribute :full_name }
    def child(data, options = {}, &block)
      @_settings[:child] << { :data => data, :options => options, :block => block }
    end

    # Glues data from a child node to the json_output
    # glue(@user) { attribute :full_name => :user_full_name }
    def glue(data, options = {}, &block)
      @_settings[:glue] << { :data => data, :options => options, :block => block }
    end

    # Extends an existing rabl template with additional attributes in the block
    # extends("users/show", :object => @user) { attribute :full_name }
    def extends(file, options = {}, &block)
      options.reverse_merge!({ :view_path => options[:view_path] || view_path })

      @_settings[:extends] << { :file => file, :options => options, :block => block }
    end

    # Includes a helper module with a RABL template
    # helper ExampleHelper
    def helper(*klasses)
      klasses.each { |klass| self.class.__send__(:include, klass) }
    end
    alias_method :helpers, :helper

    # Returns a hash representing the partial
    # partial("users/show", :object => @user)
    # options must have :object
    # options can have :view_path, :child_root, :root
    def partial(file, options = {}, &block)
      engine = partial_as_engine(file, options, &block)
      engine = engine.render if engine.is_a?(Engine)
      engine
    end

    # Disables reading (but not writing) from the cache when rendering.
    def cache_read_on_render=(read)
      @_cache_read_on_render = read
    end

    def cache_read_on_render?
      @_cache_read_on_render
    end

    protected
      # Returns a guess at the default object for this template
      # default_object => @user
      def default_object
        return unless context_scope.respond_to?(:controller)

        controller_name = context_scope.controller.controller_name
        stripped_name = controller_name.split(%r{::|\/}).last
        ivar_object = instance_variable_get("@#{stripped_name}")
        ivar_object if is_object?(ivar_object)
      end

      # Returns a guess at the format in this context_scope
      # request_format => "xml"
      def request_format
        format = request_params[:format]

        if format.nil? && context_scope.respond_to?(:request)
          request = context_scope.request
          format = request.format.to_sym.to_s if request.respond_to?(:format)
        end

        format = "json" unless format && respond_to?("to_#{format}")

        format
      end

      # Returns the request parameters if available in the context_scope
      # request_params => { :foo => "bar" }
      def request_params
        (context_scope.params if context_scope.respond_to?(:params)) || {}
      end

      # Returns data as json embraced with callback when detected
      # format_json({ :foo => "bar" }) => "test({ foo : 'bar' })"
      # format_json("{ foo : "bar" }") => "test({ foo : 'bar' })"
      def format_json(json_output)
        unless json_output.is_a?(String)
          json_engine = Rabl.configuration.json_engine
          json_output = if json_engine.respond_to?(:dump)
            json_engine.dump(json_output)
          else
            json_engine.encode(json_output)
          end
        end

        use_callback = Rabl.configuration.enable_json_callbacks && request_params[:callback].present?
        json_output = "#{request_params[:callback]}(#{json_output})" if use_callback

        json_output
      end

      # Augments respond to supporting context_scope methods
      def respond_to?(name, include_private = false)
        context_scope.respond_to?(name, include_private) || super
      end

      # Supports calling helpers defined for the template context_scope using method_missing hook
      def method_missing(name, *args, **kwargs, &block)
        return super unless context_scope.respond_to?(name, true)

        if kwargs.empty?
          context_scope.__send__(name, *args, &block)
        else
          context_scope.__send__(name, *args, **kwargs, &block)
        end
      end

      def copy_instance_variables_from(object, exclude = []) #:nodoc:
        vars = object.instance_variables - exclude
        vars.each { |name| instance_variable_set(name, object.instance_variable_get(name)) }
      end

      def reset_settings!
        @_settings[:attributes]  = []
        @_settings[:node]        = []
        @_settings[:child]       = []
        @_settings[:glue]        = []
        @_settings[:extends]     = []
      end

      # Resets the options parsed from a rabl template.
      def reset_options!
        @_options[:root_name]   = nil
        @_options[:read_multi]  = false
        @_options[:scope]       = context_scope
      end

      # Caches the results of the block based on object cache_key
      # cache_results { compile_hash(options) }
      def cache_results(&block)
        return yield unless template_cache_configured? && defined?(@_cache_key)

        if cache_read_on_render?
          fetch_result_from_cache(cache_key, @_cache_options, &block)
        else
          write_result_to_cache(cache_key, @_cache_options, &block)
        end
      end

      def digestor_available?
        defined?(Rails) && Rails.version =~ /^[4567]/
      end

      def valid_format?(format)
        SAFE_FORMATS.include?(format.to_sym) && respond_to?("to_#{format}")
      end

      def set_instance_variables!(context_scope, locals)
        @_context_scope = context_scope
        @_locals        = locals || {}

        copy_instance_variables_from(context_scope, [:@assigns, :@helpers])

        @_options[:format] ||= request_format

        # Prevent calls to inherited methods `to_yaml`, `to_enum`, etc.
        @_options[:format] = 'json' unless valid_format?(@_options[:format])

        set_locals(@_locals)
      end

      def set_locals(locals)
        locals.merge!(locals.delete(:locals) || {})
        locals.each { |key, value| instance_variable_set(:"@#{key}", value) }
      end

      def eval_source(locals, &block)
        return unless @_source.present?

        msg = "cached_source_#{@_source.hash.abs}"

        unless self.respond_to? msg then
          src = "def #{msg} locals, &block\n#{@_source}\nend"

          if @_options[:source_location]
            self.class.class_eval(src, @_options[:source_location])
          else
            self.class.class_eval(src)
          end
        end

        send msg, locals, &block
      end
  end
end