Skip to content
Permalink
3278cc6dbe
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time
164 lines (154 sloc) 6.29 KB
# frozen_string_literal: true
module Heroics
# A link invokes requests with an HTTP server.
class Link
# 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.
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
# 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.
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
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 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.
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
class CachedResponse
attr_reader :status, :body, :headers
def initialize(status, body, headers)
@status = status
@body = body
@headers = headers
end
end
end
end