contractful 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/API.md +142 -0
- data/ARCHITECTURE.md +134 -0
- data/AUTHORS +106 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +1290 -0
- data/SECURITY.md +67 -0
- data/bin/contractful +128 -0
- data/history.md +801 -0
- data/lib/contractful/abstract_response.rb +301 -0
- data/lib/contractful/circuit_breaker.rb +221 -0
- data/lib/contractful/connection_pool.rb +166 -0
- data/lib/contractful/contract/auth.rb +257 -0
- data/lib/contractful/contract/config.rb +93 -0
- data/lib/contractful/contract/definition.rb +295 -0
- data/lib/contractful/contract/dsl.rb +501 -0
- data/lib/contractful/contract/executor.rb +492 -0
- data/lib/contractful/contract/openapi_import.rb +1135 -0
- data/lib/contractful/contract/pagination.rb +218 -0
- data/lib/contractful/contract/param.rb +505 -0
- data/lib/contractful/contract/postman_import/auth.rb +39 -0
- data/lib/contractful/contract/postman_import/body.rb +87 -0
- data/lib/contractful/contract/postman_import/operation.rb +107 -0
- data/lib/contractful/contract/postman_import/scope.rb +37 -0
- data/lib/contractful/contract/postman_import/url.rb +80 -0
- data/lib/contractful/contract/postman_import.rb +164 -0
- data/lib/contractful/contract/response_field.rb +167 -0
- data/lib/contractful/contract/sequence.rb +432 -0
- data/lib/contractful/contract/swagger2/components.rb +84 -0
- data/lib/contractful/contract/swagger2/operations.rb +81 -0
- data/lib/contractful/contract/swagger2/parameters.rb +93 -0
- data/lib/contractful/contract/swagger2/references.rb +55 -0
- data/lib/contractful/contract/swagger2.rb +55 -0
- data/lib/contractful/contract.rb +85 -0
- data/lib/contractful/exceptions.rb +328 -0
- data/lib/contractful/notifications/active_support.rb +40 -0
- data/lib/contractful/notifications.rb +138 -0
- data/lib/contractful/pacer.rb +161 -0
- data/lib/contractful/params_array.rb +85 -0
- data/lib/contractful/payload.rb +340 -0
- data/lib/contractful/platform.rb +52 -0
- data/lib/contractful/rate_limit.rb +133 -0
- data/lib/contractful/raw_response.rb +70 -0
- data/lib/contractful/request.rb +1483 -0
- data/lib/contractful/resource.rb +189 -0
- data/lib/contractful/response.rb +109 -0
- data/lib/contractful/utils.rb +202 -0
- data/lib/contractful/version.rb +11 -0
- data/lib/contractful/windows/root_certs.rb +107 -0
- data/lib/contractful/windows.rb +9 -0
- data/lib/contractful.rb +233 -0
- metadata +244 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
# Use mime/types/columnar if available, for reduced memory usage
|
|
9
|
+
require 'mime/types/columnar'
|
|
10
|
+
rescue LoadError
|
|
11
|
+
require 'mime/types'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Contractful
|
|
15
|
+
# Payload generation: turns user-provided bodies (strings, hashes,
|
|
16
|
+
# ParamsArray, IO-like objects) into the object contractful transmits,
|
|
17
|
+
# deciding urlencoded vs multipart vs streamed representation.
|
|
18
|
+
module Payload
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
def generate(params)
|
|
22
|
+
# nil is a valid input — it means "no body for this request" (the
|
|
23
|
+
# common case for GET/DELETE/HEAD). Return nil so the request
|
|
24
|
+
# layer skips the body entirely. Only non-nil unknown types raise
|
|
25
|
+
# so callers find out immediately instead of silently sending an
|
|
26
|
+
# empty body (e.g. `Contractful.post(url, 123)`).
|
|
27
|
+
return nil if params.nil?
|
|
28
|
+
|
|
29
|
+
if params.is_a?(Contractful::Payload::Base)
|
|
30
|
+
# pass through Payload objects unchanged
|
|
31
|
+
params
|
|
32
|
+
elsif params.is_a?(String)
|
|
33
|
+
Base.new(params)
|
|
34
|
+
elsif params.is_a?(Hash)
|
|
35
|
+
if params[:multipart] == true
|
|
36
|
+
Multipart.new(params.reject { |key, _value| key.equal?(:multipart) })
|
|
37
|
+
elsif has_file?(params)
|
|
38
|
+
Multipart.new(params)
|
|
39
|
+
else
|
|
40
|
+
UrlEncoded.new(params)
|
|
41
|
+
end
|
|
42
|
+
elsif params.is_a?(ParamsArray)
|
|
43
|
+
if _has_file?(params)
|
|
44
|
+
Multipart.new(params)
|
|
45
|
+
else
|
|
46
|
+
UrlEncoded.new(params)
|
|
47
|
+
end
|
|
48
|
+
elsif params.respond_to?(:read)
|
|
49
|
+
Streamed.new(params)
|
|
50
|
+
else
|
|
51
|
+
# Previously this branch was silent: any value that did not match
|
|
52
|
+
# one of the cases above returned nil from generate, and the
|
|
53
|
+
# request went out with NO body — the user's payload was lost
|
|
54
|
+
# without any signal. Raise so callers find out immediately
|
|
55
|
+
# (e.g. `Contractful.post(url, 123)` or `Contractful.post(url, [1,2,3])`).
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"unsupported payload type #{params.class} " \
|
|
58
|
+
'(expected String, Hash, ParamsArray, or IO-like ' \
|
|
59
|
+
'responding to #read)'
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def has_file?(params)
|
|
64
|
+
raise ArgumentError.new("Must pass Hash, not #{params.inspect}") unless params.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
_has_file?(params)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def _has_file?(obj)
|
|
70
|
+
case obj
|
|
71
|
+
when Hash, ParamsArray
|
|
72
|
+
obj.any? { |_, v| _has_file?(v) }
|
|
73
|
+
when Array
|
|
74
|
+
obj.any? { |v| _has_file?(v) }
|
|
75
|
+
else
|
|
76
|
+
obj.respond_to?(:path) && obj.respond_to?(:read)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Base payload wrapping a readable stream; also the concrete payload
|
|
81
|
+
# for plain String bodies.
|
|
82
|
+
class Base
|
|
83
|
+
def initialize(params)
|
|
84
|
+
build_stream(params)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_stream(params)
|
|
88
|
+
@stream = StringIO.new(params)
|
|
89
|
+
@stream.seek(0)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read(*args)
|
|
93
|
+
@stream.read(*args)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_s
|
|
97
|
+
result = read
|
|
98
|
+
rewind_stream
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def headers
|
|
103
|
+
{ 'Content-Length' => size.to_s }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def size
|
|
107
|
+
@stream.size
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
alias length size
|
|
111
|
+
|
|
112
|
+
def close
|
|
113
|
+
@stream.close unless @stream.closed?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def closed?
|
|
117
|
+
@stream.closed?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Rewind the payload stream to the beginning so that the payload can be
|
|
121
|
+
# retransmitted (for example when retrying a request after a network
|
|
122
|
+
# failure). Returns true if the payload was rewound, or false if the
|
|
123
|
+
# underlying stream does not support rewinding (e.g. a socket).
|
|
124
|
+
#
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
#
|
|
127
|
+
def rewind
|
|
128
|
+
if @stream.respond_to?(:rewind)
|
|
129
|
+
@stream.rewind
|
|
130
|
+
true
|
|
131
|
+
else
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
rescue IOError, SystemCallError
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Best-effort, silent variant of #rewind used when a non-rewindable
|
|
139
|
+
# stream is not an error (for example reading the payload for logging).
|
|
140
|
+
# Streams that cannot be rewound (sockets, pipes, and other non-seekable
|
|
141
|
+
# objects) are left positioned wherever reading left them.
|
|
142
|
+
def rewind_stream
|
|
143
|
+
@stream.seek(0) if @stream.respond_to?(:seek)
|
|
144
|
+
rescue IOError, SystemCallError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def to_s_inspect
|
|
149
|
+
to_s.inspect
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def short_inspect
|
|
153
|
+
return "#{size} byte(s) length" if size && size > 500
|
|
154
|
+
return 'streaming body'.inspect unless seekable?
|
|
155
|
+
|
|
156
|
+
to_s_inspect
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# A stream that responds to #seek may still reject it (pipes and
|
|
162
|
+
# sockets raise Errno::ESPIPE). Probing the current position avoids
|
|
163
|
+
# consuming the stream merely to render it in a log line.
|
|
164
|
+
def seekable?
|
|
165
|
+
return false unless @stream.respond_to?(:seek)
|
|
166
|
+
|
|
167
|
+
@stream.seek(0, IO::SEEK_CUR)
|
|
168
|
+
true
|
|
169
|
+
rescue IOError, SystemCallError
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Payload for IO-like (streaming) bodies; the stream is consumed
|
|
175
|
+
# directly and may be non-seekable.
|
|
176
|
+
class Streamed < Base
|
|
177
|
+
def build_stream(params = nil)
|
|
178
|
+
@stream = params
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def size
|
|
182
|
+
if @stream.respond_to?(:size)
|
|
183
|
+
@stream.size
|
|
184
|
+
elsif @stream.is_a?(IO)
|
|
185
|
+
@stream.stat.size
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# TODO: (breaks compatibility): ought to use mime_for() to autodetect the
|
|
190
|
+
# Content-Type for stream objects that have a filename.
|
|
191
|
+
|
|
192
|
+
alias length size
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Payload for urlencoded form bodies (Hash/ParamsArray without files).
|
|
196
|
+
class UrlEncoded < Base
|
|
197
|
+
def build_stream(params = nil)
|
|
198
|
+
@stream = StringIO.new(Utils.encode_query_string(params))
|
|
199
|
+
@stream.seek(0)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def headers
|
|
203
|
+
super.merge({ 'Content-Type' => 'application/x-www-form-urlencoded' })
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Payload for multipart/form-data bodies (Hash/ParamsArray with file
|
|
208
|
+
# fields, or explicit multipart: true).
|
|
209
|
+
class Multipart < Base
|
|
210
|
+
EOL = "\r\n"
|
|
211
|
+
|
|
212
|
+
def build_stream(params)
|
|
213
|
+
b = '--' + boundary
|
|
214
|
+
|
|
215
|
+
@stream = Tempfile.new('contractful.multipart.')
|
|
216
|
+
@stream.binmode
|
|
217
|
+
@stream.write(b + EOL)
|
|
218
|
+
|
|
219
|
+
x = case params
|
|
220
|
+
when Hash, ParamsArray
|
|
221
|
+
Utils.flatten_params(params)
|
|
222
|
+
else
|
|
223
|
+
params
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
last_index = x.length - 1
|
|
227
|
+
x.each_with_index do |a, index|
|
|
228
|
+
key, value = * a
|
|
229
|
+
if value.respond_to?(:read) && value.respond_to?(:path)
|
|
230
|
+
create_file_field(@stream, key, value)
|
|
231
|
+
else
|
|
232
|
+
create_regular_field(@stream, key, value)
|
|
233
|
+
end
|
|
234
|
+
@stream.write(EOL + b)
|
|
235
|
+
@stream.write(EOL) unless last_index == index
|
|
236
|
+
end
|
|
237
|
+
@stream.write('--')
|
|
238
|
+
@stream.write(EOL)
|
|
239
|
+
@stream.seek(0)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def create_regular_field(stream, key, value)
|
|
243
|
+
# Multipart field names go into a quoted-string in the
|
|
244
|
+
# Content-Disposition header. A user-controlled key that contains
|
|
245
|
+
# `"` or CRLF can break out of the quoted-string and inject a
|
|
246
|
+
# synthetic part header (or another Content-Disposition with an
|
|
247
|
+
# attacker-chosen filename). Escape `"` as `\"` and strip CR/LF
|
|
248
|
+
# so the field name cannot escape its quoted-string slot.
|
|
249
|
+
safe_key = escape_form_field_name(key)
|
|
250
|
+
stream.write("Content-Disposition: form-data; name=\"#{safe_key}\"")
|
|
251
|
+
stream.write(EOL)
|
|
252
|
+
stream.write(EOL)
|
|
253
|
+
stream.write(value)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def create_file_field(stream, key, value)
|
|
257
|
+
# Same header-injection guard as create_regular_field, plus a
|
|
258
|
+
# filename escape — File.basename(value.path) is user-controlled
|
|
259
|
+
# (the path on disk of an uploaded file), and a filename containing
|
|
260
|
+
# `"`, `\r` or `\n` can inject a synthetic filename/content-type
|
|
261
|
+
# header into the multipart stream.
|
|
262
|
+
safe_key = escape_form_field_name(key)
|
|
263
|
+
stream.write('Content-Disposition: form-data;')
|
|
264
|
+
stream.write(" name=\"#{safe_key}\";") unless key.nil? || key == ''
|
|
265
|
+
filename = value.respond_to?(:original_filename) ? value.original_filename : File.basename(value.path)
|
|
266
|
+
stream.write(" filename=\"#{escape_form_field_name(filename)}\"#{EOL}")
|
|
267
|
+
content_type = value.respond_to?(:content_type) ? value.content_type : mime_for(value.path)
|
|
268
|
+
stream.write("Content-Type: #{content_type}#{EOL}")
|
|
269
|
+
stream.write(EOL)
|
|
270
|
+
while (data = value.read(8124))
|
|
271
|
+
stream.write(data)
|
|
272
|
+
end
|
|
273
|
+
ensure
|
|
274
|
+
value.close if value.respond_to?(:close)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Escape a multipart form field name (the value of `name=` or
|
|
278
|
+
# `filename=` in Content-Disposition) so the user input cannot
|
|
279
|
+
# break out of the quoted-string slot in the header.
|
|
280
|
+
#
|
|
281
|
+
# - Replace `"` with `\"` (RFC 2047 quoted-string escape).
|
|
282
|
+
# - Strip CR and LF entirely. RFC 7578 forbids them inside the
|
|
283
|
+
# token, and any CRLF ends the current header line — which is
|
|
284
|
+
# the injection vector.
|
|
285
|
+
# - Return nil for nil so the caller's `unless key.nil? || key == ''`
|
|
286
|
+
# branch above keeps working unchanged.
|
|
287
|
+
def escape_form_field_name(name)
|
|
288
|
+
return nil if name.nil?
|
|
289
|
+
|
|
290
|
+
name.to_s.tr("\r\n", '').gsub('"', '\"')
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def mime_for(path)
|
|
294
|
+
# value.path can legitimately return nil (e.g. a Tempfile that
|
|
295
|
+
# has been unlinked, or a StringIO subclass). MIME::Types.type_for
|
|
296
|
+
# calls #split on its argument, so passing nil raises
|
|
297
|
+
# NoMethodError and crashes the whole multipart build.
|
|
298
|
+
return 'text/plain' if path.nil? || path.to_s.empty?
|
|
299
|
+
|
|
300
|
+
mime = MIME::Types.type_for(path)
|
|
301
|
+
mime.empty? ? 'text/plain' : mime[0].content_type
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def boundary
|
|
305
|
+
return @boundary if defined?(@boundary) && @boundary
|
|
306
|
+
|
|
307
|
+
# Use the same algorithm used by WebKit: generate 16 random
|
|
308
|
+
# alphanumeric characters, replacing `+` `/` with `A` `B` (included in
|
|
309
|
+
# the list twice) to round out the set of 64.
|
|
310
|
+
s = SecureRandom.base64(12)
|
|
311
|
+
# Do not mutate the generated string in place: the return value of
|
|
312
|
+
# SecureRandom.base64 may be frozen, and boundary can then never be
|
|
313
|
+
# built at all.
|
|
314
|
+
s = s.tr('+/', 'AB')
|
|
315
|
+
|
|
316
|
+
@boundary = '----RubyFormBoundary' + s
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# for Multipart do not escape the keys
|
|
320
|
+
#
|
|
321
|
+
# Ostensibly multipart keys MAY be percent encoded per RFC 7578, but in
|
|
322
|
+
# practice no major browser that I'm aware of uses percent encoding.
|
|
323
|
+
#
|
|
324
|
+
# Further discussion of multipart encoding:
|
|
325
|
+
# https://github.com/rest-client/rest-client/pull/403#issuecomment-156976930
|
|
326
|
+
#
|
|
327
|
+
def handle_key key
|
|
328
|
+
key
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def headers
|
|
332
|
+
super.merge({ 'Content-Type' => "multipart/form-data; boundary=#{boundary}" })
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def close
|
|
336
|
+
@stream.close!
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
module Contractful
|
|
6
|
+
# Platform introspection: user agent strings and windows? detection.
|
|
7
|
+
module Platform
|
|
8
|
+
# Return true if we are running on a darwin-based Ruby platform. This will
|
|
9
|
+
# be false for jruby even on OS X.
|
|
10
|
+
#
|
|
11
|
+
# @return [Boolean]
|
|
12
|
+
def self.mac_mri?
|
|
13
|
+
RUBY_PLATFORM.include?('darwin')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Return true if we are running on Windows.
|
|
17
|
+
#
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
#
|
|
20
|
+
def self.windows?
|
|
21
|
+
# Ruby only sets File::ALT_SEPARATOR on Windows, and the Ruby standard
|
|
22
|
+
# library uses that to test what platform it's on.
|
|
23
|
+
!!File::ALT_SEPARATOR
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Return true if we are running on jruby.
|
|
27
|
+
#
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
#
|
|
30
|
+
def self.jruby?
|
|
31
|
+
# defined on mri >= 1.9
|
|
32
|
+
RUBY_ENGINE == 'jruby'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.architecture
|
|
36
|
+
"#{RbConfig::CONFIG['host_os']} #{RbConfig::CONFIG['host_cpu']}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.ruby_agent_version
|
|
40
|
+
case RUBY_ENGINE
|
|
41
|
+
when 'jruby'
|
|
42
|
+
"jruby/#{JRUBY_VERSION} (#{RUBY_VERSION}p#{RUBY_PATCHLEVEL})"
|
|
43
|
+
else
|
|
44
|
+
"#{RUBY_ENGINE}/#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.default_user_agent
|
|
49
|
+
"contractful/#{VERSION} (#{architecture}) #{ruby_agent_version}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Contractful
|
|
6
|
+
# Parsed rate-limit information from a response's headers.
|
|
7
|
+
#
|
|
8
|
+
# Understands both the IETF ratelimit headers (RateLimit-Limit /
|
|
9
|
+
# RateLimit-Remaining / RateLimit-Reset / RateLimit-Policy) and the legacy
|
|
10
|
+
# X-RateLimit-* convention, preferring the IETF spelling when both are
|
|
11
|
+
# present.
|
|
12
|
+
#
|
|
13
|
+
# info = response.rate_limit
|
|
14
|
+
# info.limit # => 5000
|
|
15
|
+
# info.remaining # => 4998
|
|
16
|
+
# info.reset # => Time (or nil when the server does not say)
|
|
17
|
+
# info.policy # => "10;w=1" (or nil)
|
|
18
|
+
# info.exhausted? # => true when remaining is reported as 0
|
|
19
|
+
# info.reset_in # => seconds until the quota resets (or nil)
|
|
20
|
+
#
|
|
21
|
+
# APIs may document their rate limits in prose without ever sending the
|
|
22
|
+
# headers (DhanHQ, for example) — #rate_limit then returns nil, which is
|
|
23
|
+
# data absence, not an error.
|
|
24
|
+
class RateLimit
|
|
25
|
+
# Leading-integer extraction handles both plain integers ("5000") and
|
|
26
|
+
# the IETF "limit, limit;w=window" description form.
|
|
27
|
+
LEADING_INTEGER = /\A\s*(\d+)/
|
|
28
|
+
|
|
29
|
+
attr_reader :limit, :remaining, :reset, :policy
|
|
30
|
+
|
|
31
|
+
# The raw parsed header values, for diagnostics.
|
|
32
|
+
attr_reader :raw
|
|
33
|
+
|
|
34
|
+
def initialize(limit: nil, remaining: nil, reset: nil, policy: nil, raw: {})
|
|
35
|
+
@limit = limit
|
|
36
|
+
@remaining = remaining
|
|
37
|
+
@reset = reset
|
|
38
|
+
@policy = policy
|
|
39
|
+
@raw = raw.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Build a RateLimit from a beautified headers hash (lowercase underscore
|
|
43
|
+
# symbols, as produced by AbstractResponse#headers). Returns nil when no
|
|
44
|
+
# rate-limit headers are present at all.
|
|
45
|
+
def self.from_headers(headers)
|
|
46
|
+
return nil unless headers.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
parsed = {}
|
|
49
|
+
parsed[:limit] = parse_limit(pick(headers, :ratelimit_limit, :x_ratelimit_limit))
|
|
50
|
+
parsed[:remaining] = parse_integer(pick(headers, :ratelimit_remaining, :x_ratelimit_remaining))
|
|
51
|
+
parsed[:reset] = parse_reset(pick(headers, :ratelimit_reset),
|
|
52
|
+
pick(headers, :x_ratelimit_reset))
|
|
53
|
+
parsed[:policy] = pick(headers, :ratelimit_policy)
|
|
54
|
+
|
|
55
|
+
raw = {}
|
|
56
|
+
headers.each do |key, value|
|
|
57
|
+
raw[key] = value if key.to_s.start_with?('ratelimit_', 'x_ratelimit_')
|
|
58
|
+
end
|
|
59
|
+
parsed[:raw] = raw
|
|
60
|
+
|
|
61
|
+
return nil unless parsed[:limit] || parsed[:remaining] || parsed[:reset] || parsed[:policy]
|
|
62
|
+
|
|
63
|
+
new(**parsed)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True when the server reports the quota as used up.
|
|
67
|
+
def exhausted?
|
|
68
|
+
remaining == 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Seconds until the quota resets (never negative), or nil when unknown.
|
|
72
|
+
def reset_in
|
|
73
|
+
return nil unless reset
|
|
74
|
+
|
|
75
|
+
secs = reset - Time.now
|
|
76
|
+
secs.positive? ? secs : 0.0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_s
|
|
80
|
+
parts = []
|
|
81
|
+
parts << "#{remaining}/#{limit}" if limit || remaining
|
|
82
|
+
parts << "resets in #{reset_in&.round(3)}s" if reset
|
|
83
|
+
parts << "policy #{policy}" if policy
|
|
84
|
+
parts.empty? ? 'RateLimit (no data)' : "RateLimit #{parts.join(', ')}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"#<Contractful::RateLimit limit=#{limit.inspect} remaining=#{remaining.inspect} " \
|
|
89
|
+
"reset=#{reset.inspect} policy=#{policy.inspect}>"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Pick the first present value among candidate header keys (keys are
|
|
93
|
+
# beautified symbols; values may be single strings or arrays).
|
|
94
|
+
def self.pick(headers, *names)
|
|
95
|
+
names.each do |name|
|
|
96
|
+
value = headers[name]
|
|
97
|
+
value = value.first if value.is_a?(Array)
|
|
98
|
+
value = value.to_s.strip
|
|
99
|
+
return value unless value.empty?
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.parse_limit(value)
|
|
105
|
+
return nil unless value
|
|
106
|
+
|
|
107
|
+
match = value.match(LEADING_INTEGER)
|
|
108
|
+
match && match[1].to_i
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.parse_integer(value)
|
|
112
|
+
parse_limit(value)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# IETF reset is a duration in seconds or an HTTP-date; the legacy
|
|
116
|
+
# X-RateLimit-Reset is an epoch timestamp.
|
|
117
|
+
def self.parse_reset(ietf_value, legacy_value)
|
|
118
|
+
if ietf_value
|
|
119
|
+
if ietf_value.match?(/\A\d+\z/)
|
|
120
|
+
Time.now + ietf_value.to_f
|
|
121
|
+
else
|
|
122
|
+
begin
|
|
123
|
+
Time.httpdate(ietf_value)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
elsif legacy_value&.match?(/\A\d+\z/)
|
|
129
|
+
Time.at(legacy_value.to_i)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
# The response from Contractful on a raw request looks like a string, but is
|
|
5
|
+
# actually one of these. 99% of the time you're making a rest call all you
|
|
6
|
+
# care about is the body, but on the occasion you want to fetch the
|
|
7
|
+
# headers you can:
|
|
8
|
+
#
|
|
9
|
+
# Contractful.get('http://example.com').headers[:content_type]
|
|
10
|
+
#
|
|
11
|
+
# In addition, if you do not use the response as a string, you can access
|
|
12
|
+
# a Tempfile object at res.file, which contains the path to the raw
|
|
13
|
+
# downloaded request body.
|
|
14
|
+
class RawResponse
|
|
15
|
+
include AbstractResponse
|
|
16
|
+
|
|
17
|
+
attr_reader :file, :request, :start_time, :end_time
|
|
18
|
+
|
|
19
|
+
def inspect
|
|
20
|
+
"<Contractful::RawResponse @code=#{code.inspect}, @file=#{file.inspect}, @request=#{request.inspect}>"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param [Tempfile] tempfile The temporary file containing the body
|
|
24
|
+
# @param [Net::HTTPResponse] net_http_res
|
|
25
|
+
# @param [Contractful::Request] request
|
|
26
|
+
# @param [Time] start_time
|
|
27
|
+
def initialize(tempfile, net_http_res, request, start_time = nil)
|
|
28
|
+
@file = tempfile
|
|
29
|
+
|
|
30
|
+
# reopen the tempfile so we can read it. Tempfile#open does NOT
|
|
31
|
+
# preserve the binmode flag that fetch_body_to_tempfile set
|
|
32
|
+
# (https://github.com/ruby/tempfile/blob/v0.3.0/lib/tempfile.rb).
|
|
33
|
+
# On Windows the reopened file is text mode, which corrupts binary
|
|
34
|
+
# bodies (image, gzip) via CRLF translation. Re-apply binmode
|
|
35
|
+
# unconditionally; on Linux it is a no-op.
|
|
36
|
+
@file.open
|
|
37
|
+
@file.binmode
|
|
38
|
+
|
|
39
|
+
response_set_vars(net_http_res, request, start_time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def body
|
|
47
|
+
@file.rewind
|
|
48
|
+
@file.read
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def size
|
|
52
|
+
file.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Close and unlink the underlying tempfile. Long-lived processes
|
|
56
|
+
# that issue many raw_response: true requests leak a file
|
|
57
|
+
# descriptor per RawResponse until GC finalizes the Tempfile — call
|
|
58
|
+
# this method when the body is no longer needed to release the FD
|
|
59
|
+
# and the on-disk file immediately. After this call, #body and
|
|
60
|
+
# #size raise (the file is gone); callers that want to keep the
|
|
61
|
+
# body around should read it into a String before closing.
|
|
62
|
+
def close
|
|
63
|
+
return if @file.nil?
|
|
64
|
+
|
|
65
|
+
@file.close(true) if @file.respond_to?(:close)
|
|
66
|
+
@file = nil
|
|
67
|
+
end
|
|
68
|
+
alias close! close
|
|
69
|
+
end
|
|
70
|
+
end
|