zendesk/zendesk_api_client_rb

View on GitHub
lib/zendesk_api/collection.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'zendesk_api/resource'
require 'zendesk_api/resources'
require 'zendesk_api/search'
require 'zendesk_api/pagination'

module ZendeskAPI
  # Represents a collection of resources. Lazily loaded, resources aren't
  # actually fetched until explicitly needed (e.g. #each, {#fetch}).
  class Collection
    include ZendeskAPI::Sideloading
    include Pagination

    # Options passed in that are automatically converted from an array to a comma-separated list.
    SPECIALLY_JOINED_PARAMS = [:ids, :only]

    # @return [ZendeskAPI::Association] The class association
    attr_reader :association

    # @return [Faraday::Response] The last response
    attr_reader :response

    # @return [Hash] query options
    attr_reader :options

    # @return [ZendeskAPI::ClientError] The last response error
    attr_reader :error

    # Creates a new Collection instance. Does not fetch resources.
    # Additional options are: verb (default: GET), path (default: resource param), page, per_page.
    # @param [Client] client The {Client} to use.
    # @param [String] resource The resource being collected.
    # @param [Hash] options Any additional options to be passed in.
    def initialize(client, resource, options = {})
      @client, @resource_class, @resource = client, resource, resource.resource_path
      @options = SilentMash.new(options)

      set_association_from_options
      join_special_params

      @verb = @options.delete(:verb)
      @includes = Array(@options.delete(:include))

      # Used for Attachments, TicketComment
      if @resource_class.is_a?(Class) && @resource_class.superclass == ZendeskAPI::Data
        @resources = []
        @fetchable = false
      else
        @fetchable = true
      end
    end

    # Methods that take a Hash argument
    methods = %w{create find update update_many destroy create_or_update}
    methods += methods.map { |method| method + "!" }
    methods.each do |deferrable|
      # Passes arguments and the proper path to the resource class method.
      # @param [Hash] options Options or attributes to pass
      define_method deferrable do |*args|
        unless @resource_class.respond_to?(deferrable)
          raise NoMethodError.new("undefined method \"#{deferrable}\" for #{@resource_class}", deferrable, args)
        end

        args << {} unless args.last.is_a?(Hash)
        args.last.merge!(:association => @association)

        @resource_class.send(deferrable, @client, *args)
      end
    end

    # Methods that take an Array argument
    methods = %w{create_many! destroy_many!}
    methods.each do |deferrable|
      # Passes arguments and the proper path to the resource class method.
      # @param [Array] array arguments
      define_method deferrable do |*args|
        unless @resource_class.respond_to?(deferrable)
          raise NoMethodError.new("undefined method \"#{deferrable}\" for #{@resource_class}", deferrable, args)
        end

        array = args.last.is_a?(Array) ? args.pop : []

        @resource_class.send(deferrable, @client, array, @association)
      end
    end

    # Convenience method to build a new resource and
    # add it to the collection. Fetches the collection as well.
    # @param [Hash] opts Options or attributes to pass
    def build(opts = {})
      wrap_resource(opts, true).tap do |res|
        self << res
      end
    end

    # Convenience method to build a new resource and
    # add it to the collection. Fetches the collection as well.
    # @param [Hash] opts Options or attributes to pass
    def build!(opts = {})
      wrap_resource(opts, true).tap do |res|
        fetch!

        # << does a fetch too
        self << res
      end
    end

    # @return [Number] The total number of resources server-side (disregarding pagination).
    def count
      fetch
      @count || -1
    end

    # @return [Number] The total number of resources server-side (disregarding pagination).
    def count!
      fetch!
      @count || -1
    end

    # Saves all newly created resources stored in this collection.
    # @return [Collection] self
    def save
      _save
    end

    # Saves all newly created resources stored in this collection.
    # @return [Collection] self
    def save!
      _save(:save!)
    end

    # Adds an item (or items) to the list of side-loaded resources to request
    # @option sideloads [Symbol or String] The item(s) to sideload
    def include(*sideloads)
      tap { @includes.concat(sideloads.map(&:to_s)) }
    end

    # Adds an item to this collection
    # @option item [ZendeskAPI::Data] the resource to add
    # @raise [ArgumentError] if the resource doesn't belong in this collection
    def <<(item)
      fetch

      if item.is_a?(Resource)
        if item.is_a?(@resource_class)
          @resources << item
        else
          raise "this collection is for #{@resource_class}"
        end
      else
        @resources << wrap_resource(item, true)
      end
    end

    # The API path to this collection
    def path
      @association.generate_path(:with_parent => true)
    end

    # Executes actual GET from API and loads resources into proper class.
    # @param [Boolean] reload Whether to disregard cache
    def fetch!(reload = false)
      if @resources && (!@fetchable || !reload)
        return @resources
      elsif association && association.options.parent && association.options.parent.new_record?
        return (@resources = [])
      end

      get_resources(@query || path)
    end

    def fetch(*args)
      fetch!(*args)
    rescue Faraday::ClientError => e
      @error = e

      []
    end

    # Alias for fetch(false)
    def to_a
      fetch
    end

    # Alias for fetch!(false)
    def to_a!
      fetch!
    end

    # Calls #each on every page with the passed in block
    # @param [Block] block Passed to #each
    def all!(start_page = @options["page"], &block)
      _all(start_page, :bang, &block)
    end

    # Calls #each on every page with the passed in block
    # @param [Block] block Passed to #each
    def all(start_page = @options["page"], &block)
      _all(start_page, &block)
    end

    def each_page!(*args, &block)
      warn "ZendeskAPI::Collection#each_page! is deprecated, please use ZendeskAPI::Collection#all!"
      all!(*args, &block)
    end

    def each_page(*args, &block)
      warn "ZendeskAPI::Collection#each_page is deprecated, please use ZendeskAPI::Collection#all"
      all(*args, &block)
    end

    # Replaces the current (loaded or not) resources with the passed in collection
    # @option collection [Array] The collection to replace this one with
    # @raise [ArgumentError] if any resources passed in don't belong in this collection
    def replace(collection)
      raise "this collection is for #{@resource_class}" if collection.any? { |r| !r.is_a?(@resource_class) }
      @resources = collection
    end

    # Find the next page. Does one of three things:
    # * If there is already a page number in the options hash, it increases it and invalidates the cache, returning the new page number.
    # * If there is a next_page url cached, it executes a fetch on that url and returns the results.
    # * Otherwise, returns an empty array.
    def next
      if @options["page"] && !cbp_request?
        clear_cache
        @options["page"] = @options["page"].to_i + 1
      elsif (@query = @next_page)
        # Send _only_ url param "?page[after]=token" to get the next page
        @options.page&.delete("before")
        fetch(true)
      else
        clear_cache
        @resources = []
      end
    end

    # Find the previous page. Does one of three things:
    # * If there is already a page number in the options hash, it increases it and invalidates the cache, returning the new page number.
    # * If there is a prev_page url cached, it executes a fetch on that url and returns the results.
    # * Otherwise, returns an empty array.
    def prev
      if !cbp_request? && @options["page"].to_i > 1
        clear_cache
        @options["page"] -= 1
      elsif (@query = @prev_page)
        # Send _only_ url param "?page[before]=token" to get the prev page
        @options.page&.delete("after")
        fetch(true)
      else
        clear_cache
        @resources = []
      end
    end

    # Clears all cached resources and associated values.
    def clear_cache
      @resources = nil
      @count = nil
      @next_page = nil
      @prev_page = nil
      @query = nil
    end

    # @private
    def to_ary
      nil
    end

    def respond_to_missing?(name, include_all)
      [].respond_to?(name, include_all)
    end

    # Sends methods to underlying array of resources.
    def method_missing(name, *args, &block)
      if resource_methods.include?(name)
        collection_method(name, *args, &block)
      elsif [].respond_to?(name, false)
        array_method(name, *args, &block)
      else
        next_collection(name, *args, &block)
      end
    end

    # @private
    def to_s
      if @resources
        @resources.inspect
      else
        inspect = []
        inspect << "options=#{@options.inspect}" if @options.any?
        inspect << "path=#{path}"
        "#{Inflection.singular(@resource)} collection [#{inspect.join(',')}]"
      end
    end

    alias to_str to_s

    def to_param
      map(&:to_param)
    end

    def get_next_page_data(original_response_body)
      link = original_response_body["links"]["next"]
      result_key = @resource_class.model_key || "results"
      while link
        response = @client.connection.send("get", link).body

        original_response_body[result_key] = original_response_body[result_key] + response[result_key]

        link = response["meta"]["has_more"] ? response["links"]["next"] : nil
      end

      original_response_body
    end

    private

    def get_resources(path_query_link)
      if intentional_obp_request?
        warn "Offset Based Pagination will be deprecated soon"
      elsif supports_cbp? && first_cbp_request?
        # only set cbp options if it's the first request, otherwise the options would be already in place
        set_cbp_options
      end
      @response = get_response(path_query_link)

      # Keep pre-existing behaviour for search/export
      if path_query_link == "search/export"
        handle_search_export_response(@response.body)
      else
        handle_response(@response.body)
      end
    end

    def _all(start_page = @options["page"], bang = false, &block)
      raise(ArgumentError, "must pass a block") unless block

      page(start_page)
      clear_cache

      while (bang ? fetch! : fetch)
        each do |resource|
          block.call(resource, @options["page"] || 1)
        end

        last_page? ? break : self.next
      end

      page(nil)
      clear_cache
    end

    def _save(method = :save)
      return self unless @resources

      result = true

      @resources.map! do |item|
        if item.respond_to?(method) && !item.destroyed? && item.changed?
          result &&= item.send(method)
        end

        item
      end

      result
    end

    def join_special_params
      # some params use comma-joined strings instead of query-based arrays for multiple values
      @options.each do |k, v|
        if SPECIALLY_JOINED_PARAMS.include?(k.to_sym) && v.is_a?(Array)
          @options[k] = v.join(',')
        end
      end
    end

    def set_association_from_options
      @collection_path = @options.delete(:collection_path)

      association_options = { :path => @options.delete(:path) }
      association_options[:path] ||= @collection_path.join("/") if @collection_path
      @association = @options.delete(:association) || Association.new(association_options.merge(:class => @resource_class))
      @collection_path ||= [@resource]
    end

    def get_response(path)
      @error = nil
      @client.connection.send(@verb || "get", path) do |req|
        opts = @options.delete_if { |_, v| v.nil? }

        req.params.merge!(:include => @includes.join(",")) if @includes.any?

        if %w{put post}.include?(@verb.to_s)
          req.body = opts
        else
          req.params.merge!(opts)
        end
      end
    end

    def handle_search_export_response(response_body)
      assert_valid_response_body(response_body)

      # Note this doesn't happen in #handle_response
      response_body = get_next_page_data(response_body) if more_results?(response_body)

      body = response_body.dup
      results = body.delete(@resource_class.model_key) || body.delete("results")

      assert_results(results, body)

      @resources = results.map do |res|
        wrap_resource(res)
      end
    end

    # For both CBP and OBP
    def handle_response(response_body)
      assert_valid_response_body(response_body)

      body = response_body.dup
      results = body.delete(@resource_class.model_key) || body.delete("results")

      assert_results(results, body)

      @resources = results.map do |res|
        wrap_resource(res)
      end

      set_page_and_count(body)
      set_includes(@resources, @includes, body)

      @resources
    end

    # Simplified Associations#wrap_resource
    def wrap_resource(res, with_association = with_association?)
      case res
      when Array
        wrap_resource(Hash[*res], with_association)
      when Hash
        res = res.merge(:association => @association) if with_association
        @resource_class.new(@client, res)
      else
        res = { :id => res }
        res.merge!(:association => @association) if with_association
        @resource_class.new(@client, res)
      end
    end

    # Two special cases, and all namespaced classes
    def with_association?
      [Tag, Setting].include?(@resource_class) ||
        @resource_class.to_s.split("::").size > 2
    end

    ## Method missing

    def array_method(name, *args, &block)
      to_a.public_send(name, *args, &block)
    end

    # If you call client.tickets.foo - and foo is not an attribute nor an association, it ends up here, as a new collection
    def next_collection(name, *args, &block)
      opts = args.last.is_a?(Hash) ? args.last : {}
      opts.merge!(collection_path: [*@collection_path, name], page: nil)
      # Why `page: nil`?
      # when you do client.tickets.fetch followed by client.tickets.foos => the request to /tickets/foos will
      # have the options page set to whatever the last options were for the tickets collection
      self.class.new(@client, @resource_class, @options.merge(opts))
    end

    def collection_method(name, *args, &block)
      @resource_class.send(name, @client, *args, &block)
    end

    def resource_methods
      @resource_methods ||= @resource_class.singleton_methods(false).map(&:to_sym)
    end

    def assert_valid_response_body(response_body)
      unless response_body.is_a?(Hash)
        raise ZendeskAPI::Error::NetworkError, @response.env
      end
    end

    def assert_results(results, body)
      return if results
      raise ZendeskAPI::Error::ClientError, "Expected #{@resource_class.model_key} or 'results' in response keys: #{body.keys.inspect}"
    end
  end
end