dashofcode/tracker_api

View on GitHub
lib/tracker_api/client.rb

Summary

Maintainability
A
1 hr
Test Coverage
module TrackerApi
  class Client
    USER_AGENT          = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}; #{RUBY_ENGINE}) TrackerApi/#{TrackerApi::VERSION} Faraday/#{Faraday::VERSION}".freeze

    # Header keys that can be passed in options hash to {#get},{#paginate}
    CONVENIENCE_HEADERS = Set.new([:accept, :content_type])

    attr_reader :url, :api_version, :token, :logger, :connection, :auto_paginate, :last_response

    # Create Pivotal Tracker API client.
    #
    # @param [Hash] options the connection options
    # @option options [String] :token API token to use for requests
    # @option options [String] :url Main HTTP API root
    # @option options [Boolean] :auto_paginate Client should perform pagination automatically. Default true.
    # @option options [String] :api_version The API version URL path
    # @option options [String] :logger Custom logger
    # @option options [String] :adapter Custom http adapter to configure Faraday with
    # @option options [String] :connection_options Connection options to pass to Faraday
    #
    # @example Creating a Client
    #   Client.new token: 'my-super-special-token'
    def initialize(options={}, &block)
      url                = options.fetch(:url, 'https://www.pivotaltracker.com')
      @url               = Addressable::URI.parse(url).to_s
      @api_version       = options.fetch(:api_version, '/services/v5')
      @logger            = options.fetch(:logger, ::Logger.new(nil))
      adapter            = options.fetch(:adapter, :net_http)
      connection_options = options.fetch(:connection_options, { ssl: { verify: true } })
      @auto_paginate     = options.fetch(:auto_paginate, true)
      @token             = options[:token]
      raise 'Missing required options: :token' unless @token

      @faraday_block = block if block_given?

      @connection = Faraday.new({ url: @url }.merge(connection_options)) do |builder|
        # response
        builder.use Faraday::Response::RaiseError
        builder.response :json, content_type: /\bjson/ # e.g., 'application/json; charset=utf-8'

        # request
        builder.request :multipart
        builder.request :json

        builder.use TrackerApi::Logger, @logger
        @faraday_block.call(builder) if @faraday_block
        builder.adapter adapter
      end
    end

    # HTTP requests methods
    #
    # @param path [String] The path, relative to api endpoint
    # @param options [Hash] Query and header params for request
    # @return [Faraday::Response]
    %i{get post patch put delete}.each do |verb|
      define_method verb do |path, options = {}|
        request(verb, parse_query_and_convenience_headers(path, options))
      end
    end

    # Make one or more HTTP GET requests, optionally fetching
    # the next page of results from information passed back in headers
    # based on value in {#auto_paginate}.
    #
    # @param path [String] The path, relative to {#api_endpoint}
    # @param options [Hash] Query and header params for request
    # @param block [Block] Block to perform the data concatenation of the
    #   multiple requests. The block is called with two parameters, the first
    #   contains the contents of the requests so far and the second parameter
    #   contains the latest response.
    # @return [Array]
    def paginate(path, options = {}, &block)
      opts           = parse_query_and_convenience_headers path, options.dup
      auto_paginate  = opts[:params].delete(:auto_paginate) { |k| @auto_paginate }
      @last_response = request :get, opts
      data           = @last_response.body
      raise TrackerApi::Errors::UnexpectedData, 'Array expected' unless data.is_a? Array

      if auto_paginate
        pager = Pagination.new @last_response.headers

        while pager.more?
          opts[:params].update(pager.next_page_params)

          @last_response = request :get, opts
          pager          = Pagination.new @last_response.headers
          if block_given?
            yield(data, @last_response)
          else
            data.concat(@last_response.body) if @last_response.body.is_a?(Array)
          end
        end
      end

      data
    end

    # Get projects
    #
    # @param [Hash] params
    # @return [Array[TrackerApi::Resources::Project]]
    def projects(params={})
      Endpoints::Projects.new(self).get(params)
    end

    # Get project
    #
    # @param [Hash] params
    # @return [TrackerApi::Resources::Project]
    def project(id, params={})
      Endpoints::Project.new(self).get(id, params)
    end

    # Create a new workspace.
    #
    # @param [Hash] params attributes to create the workspace with
    # @return [TrackerApi::Resources::Workspace] newly created Workspace
    def create_workspace(params)
      Endpoints::Workspace.new(self).create(params)
    end

    # Get workspace
    #
    # @param [Hash] params
    # @return [TrackerApi::Resources::Workspace]
    def workspace(id, params={})
      Endpoints::Workspace.new(self).get(id, params)
    end

    # Get workspaces
    #
    # @param [Hash] params
    # @return [Array[TrackerApi::Resources::Workspace]]
    def workspaces(params={})
      Endpoints::Workspaces.new(self).get(params)
    end


    # Get information about the authenticated user
    #
    # @return [TrackerApi::Resources::Me]
    def me
      Endpoints::Me.new(self).get
    end

    # Get information about a client story without knowing what project the story belongs to
    #
    # @param [String] story_id
    # @param [Hash] params
    # @return [TrackerApi::Resources::Story]
    def story(story_id, params={})
      Endpoints::Story.new(self).get_story(story_id, params)
    end

    # Get information about an epic without knowing what project the epic belongs to
    #
    # @param [String] epic_id
    # @param [Hash] params
    # @return [TrackerApi::Resources::Epic]
    def epic(epic_id, params={})
      Endpoints::Epic.new(self).get_epic(epic_id, params)
    end

    # Get notifications for the authenticated person
    #
    # @param [Hash] params
    # @return [Array[TrackerApi::Resources::Notification]]
    def notifications(params={})
      Endpoints::Notifications.new(self).get(params)
    end

    # Provides a list of all the activity performed the authenticated person.
    #
    # @param [Hash] params
    # @return [Array[TrackerApi::Resources::Activity]]
    def activity(params={})
      Endpoints::Activity.new(self).get(params)
    end

    private

    def parse_query_and_convenience_headers(path, options)
      raise 'Path can not be blank.' if path.to_s.empty?

      opts = { body: options[:body] }

      opts[:url]    = options[:url] || File.join(@url, @api_version, path.to_s)
      opts[:method] = options[:method] || :get
      opts[:params] = options[:params] || {}
      opts[:token]  = options[:token] || @token
      headers       = { 'User-Agent'     => USER_AGENT,
                        'X-TrackerToken' => opts.fetch(:token),
                        'Accept' => 'application/json' }.merge(options.fetch(:headers, {}))

      CONVENIENCE_HEADERS.each do |h|
        if header = options[h]
          headers[h] = header
        end
      end
      opts[:headers] = headers

      opts
    end

    def request(method, options = {})
      url     = options.fetch(:url)
      params  = options[:params] || {}
      body    = options[:body]
      headers = options[:headers]

      if (method == :post || method == :put) && options[:body].blank?
        body                    = MultiJson.dump(params)
        headers['Content-Type'] = 'application/json'

        params = {}
      end

      @last_response = response = connection.send(method) do |req|
        req.url(url)
        req.headers.merge!(headers)
        req.params.merge!(params)
        req.body = body
      end
      response
    rescue Faraday::ClientError, Faraday::ServerError => e
      status_code = e.response[:status]
      case status_code
      when 400..499 then raise TrackerApi::Errors::ClientError.new(e)
      when 500..599 then raise TrackerApi::Errors::ServerError.new(e)
      else raise "Expected 4xx or 5xx HTTP status code; got #{status_code} instead."
      end
    end

    class Pagination
      attr_accessor :headers, :total, :limit, :offset, :returned

      def initialize(headers)
        @headers  = headers
        @total    = headers['x-tracker-pagination-total'].to_i
        @limit    = headers['x-tracker-pagination-limit'].to_i
        @offset   = headers['x-tracker-pagination-offset'].to_i
        @returned = headers['x-tracker-pagination-returned'].to_i

        # if offset is negative (e.g. Iterations Endpoint).
        #   For the 'Done' scope, negative numbers can be passed, which
        #   specifies the number of iterations preceding the 'Current' iteration.
        # then need to adjust the negative offset to account for a smaller total,
        #   and set total to zero since we are paginating from -X to 0.
        if @offset < 0
          @offset = -@total if @offset.abs > @total
          @total  = 0
        end
      end

      def more?
        (offset + limit) < total
      end

      def next_offset
        offset + limit
      end

      def next_page_params
        { limit: limit, offset: next_offset }
      end
    end
  end
end