-
Notifications
You must be signed in to change notification settings - Fork 34
/
link.rb
164 lines (154 loc) · 6.29 KB
/
link.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# 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