class Heroics::Link

A link invokes requests with an HTTP server.

Public Class Methods

new(url, link_schema, options={}) click to toggle source

Instantiate a link.

@param url [String] The URL to use when making requests. Include the

username and password to use with HTTP basic auth.

@param link_schema [LinkSchema] The schema for this link. @param options [Hash] Configuration for the link. Possible keys

include:
- default_headers: Optionally, a set of headers to include in every
  request made by the client.  Default is no custom headers.
- cache: Optionally, a Moneta-compatible cache to store ETags.
  Default is no caching.
# File lib/heroics/link.rb, line 16
def initialize(url, link_schema, options={})
  @root_url, @path_prefix = unpack_url(url)
  @link_schema = link_schema
  @default_headers = options[:default_headers] || {}
  @cache = options[:cache] || {}
  @rate_throttle = options[:rate_throttle] || Heroics::Configuration.defaults.options[:rate_throttle]
  @status_codes = options[:status_codes] || Heroics::Configuration.defaults.options[:status_codes]
end

Public Instance Methods

run(*parameters) click to toggle source

Make a request to the server.

JSON content received with an ETag is cached. When the server returns a *304 Not Modified* status code content is loaded and returned from the cache. The cache considers headers, in addition to the URL path, when creating keys so that requests to the same path, such as for paginated results, don’t cause cache collisions.

When the server returns a *206 Partial Content* status code the result is assumed to be an array and an enumerator is returned. The enumerator yields results from the response until they’ve been consumed at which point, if additional content is available from the server, it blocks and makes a request to fetch the subsequent page of data. This behaviour continues until the client stops iterating the enumerator or the dataset from the server has been entirely consumed.

@param parameters [Array] The list of parameters to inject into the

path.  A request body can be passed as the final parameter and will
always be converted to JSON before being transmitted.

@raise [ArgumentError] Raised if either too many or too few parameters

were provided.

@return [String,Object,Enumerator] A string for text responses, an

object for JSON responses, or an enumerator for list responses.
# File lib/heroics/link.rb, line 48
def run(*parameters)
  path, body = @link_schema.format_path(parameters)
  path = "#{@path_prefix}#{path}" unless @path_prefix == '/'
  headers = @default_headers
  if body
    case @link_schema.method
    when :put, :post, :patch
      headers = headers.merge({'Content-Type' => @link_schema.content_type})
      body = @link_schema.encode(body)
    when :get, :delete
      if body.is_a?(Hash)
        query = body
      else
        query = MultiJson.load(body)
      end
      body = nil
    end
  end

  connection = Excon.new(@root_url, thread_safe_sockets: true)
  response = request_with_cache(connection,
                                method: @link_schema.method, path: path,
                                headers: headers, body: body, query: query,
                                expects: [200, 201, 202, 204, 206])
  content_type = response.headers['Content-Type']
  if content_type && content_type =~ /application\/.*json/
    body = MultiJson.load(response.body)
    if response.status == 206
      next_range = response.headers['Next-Range']
      Enumerator.new do |yielder|
        while true do
          # Yield the results we got in the body.
          body.each do |item|
            yielder << item
          end

          # Only make a request to get the next page if we have a valid
          # next range.
          break unless next_range
          headers = headers.merge({'Range' => next_range})
          response = request_with_cache(connection,
                                        method: @link_schema.method,
                                        path: path, headers: headers,
                                        expects: [200, 201, 206])
          body = MultiJson.load(response.body)
          next_range = response.headers['Next-Range']
        end
      end
    else
      body
    end
  elsif !response.body.empty?
    response.body
  end
end

Private Instance Methods

request_with_cache(connection, options) click to toggle source
# File lib/heroics/link.rb, line 106
def request_with_cache(connection, options)
  options[:expects] << 304
  @status_codes.each do |code|
    options[:expects] << code
  end
  cache_key = "#{options[:path]}:#{options[:headers].hash}"
  if options[:method] == :get
    etag = @cache["etag:#{cache_key}"]
    options[:headers] = options[:headers].merge({'If-None-Match' => etag}) if etag
  end

  response = @rate_throttle.call do
    connection.request(options)
  end

  if response.status == 304
    body = @cache["data:#{cache_key}"]
    status = @cache["status:#{cache_key}"]
    headers = @cache["headers:#{cache_key}"]
    CachedResponse.new(status, body, headers)
  else
    if etag = response.headers['ETag']
      @cache["etag:#{cache_key}"] = etag
      @cache["data:#{cache_key}"] = response.body
      @cache["status:#{cache_key}"] = response.status
      @cache["headers:#{cache_key}"] = response.headers
    end
    response
  end
end
unpack_url(url) click to toggle source

Unpack the URL and split it into a root URL and a path prefix, if one exists.

@param url [String] The complete base URL to use when making requests. @return [String,String] A (root URL, path) prefix pair.

# File lib/heroics/link.rb, line 142
def unpack_url(url)
  root_url = []
  path_prefix = ''
  parts = URI.split(url)
  root_url << "#{parts[0]}://"
  root_url << "#{parts[1]}@" unless parts[1].nil?
  root_url << "#{parts[2]}"
  root_url << ":#{parts[3]}" unless parts[3].nil?
  path_prefix = parts[5]
  return root_url.join(''), path_prefix
end