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,1483 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require 'netrc'
|
|
6
|
+
require 'set'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
# Use mime/types/columnar if available, for reduced memory usage
|
|
11
|
+
require 'mime/types/columnar'
|
|
12
|
+
rescue LoadError
|
|
13
|
+
require 'mime/types'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Contractful
|
|
17
|
+
# This class is used internally by Contractful to send the request, but you can also
|
|
18
|
+
# call it directly if you'd like to use a method not supported by the
|
|
19
|
+
# main API. For example:
|
|
20
|
+
#
|
|
21
|
+
# Contractful::Request.execute(:method => :head, :url => 'http://example.com')
|
|
22
|
+
#
|
|
23
|
+
# Mandatory parameters:
|
|
24
|
+
# * :method
|
|
25
|
+
# * :url
|
|
26
|
+
# Optional parameters (have a look at ssl and/or uri for some explanations):
|
|
27
|
+
# * :headers a hash containing the request headers
|
|
28
|
+
# * :cookies may be a Hash{String/Symbol => String} of cookie values, an
|
|
29
|
+
# Array<HTTP::Cookie>, or an HTTP::CookieJar containing cookies. These
|
|
30
|
+
# will be added to a cookie jar before the request is sent.
|
|
31
|
+
# * :user and :password for basic auth, will be replaced by a user/password available in the :url
|
|
32
|
+
# * :block_response call the provided block with the HTTPResponse as parameter
|
|
33
|
+
# * :raw_response return a low-level RawResponse instead of a Response
|
|
34
|
+
# * :log Set the log for this request only, overriding Contractful.log, if
|
|
35
|
+
# any.
|
|
36
|
+
# * :stream_log_percent (Only relevant with :raw_response => true) Customize
|
|
37
|
+
# the interval at which download progress is logged. Defaults to every
|
|
38
|
+
# 10% complete.
|
|
39
|
+
# * :max_redirects maximum number of redirections (default to 10)
|
|
40
|
+
# * :max_retries number of times to retry the request when a transient
|
|
41
|
+
# network failure occurs (connection timeouts, broken/reset connections,
|
|
42
|
+
# EPIPE) or when the response carries a retryable HTTP status (see
|
|
43
|
+
# :retry_after). Defaults to 0 (disabled). Retries use exponential
|
|
44
|
+
# backoff (or the server's Retry-After for HTTP statuses).
|
|
45
|
+
# * :idempotent declares that repeating the request has the same effect as
|
|
46
|
+
# sending it once. Defaults to true for GET, HEAD, OPTIONS, TRACE, PUT and
|
|
47
|
+
# DELETE (RFC 9110 §9.2.2) and to false otherwise. A positive :max_retries
|
|
48
|
+
# requires an idempotent request: a timed-out POST may already have been
|
|
49
|
+
# processed by the server, so retrying it can repeat the operation.
|
|
50
|
+
# * :retry_interval base delay in seconds between retries (default 0.5).
|
|
51
|
+
# The delay doubles with each retry: retry_interval, retry_interval * 2,
|
|
52
|
+
# retry_interval * 4, ...
|
|
53
|
+
# * :retry_after honor Retry-After / classify retryable HTTP statuses
|
|
54
|
+
# (408, 425, 429, 500, 502, 503, 504 — see
|
|
55
|
+
# Contractful::Exceptions::RETRIABLE_STATUSES) and retry them like
|
|
56
|
+
# network failures when :max_retries is positive. Defaults to true.
|
|
57
|
+
# The delay between attempts honors a server-provided Retry-After
|
|
58
|
+
# header (delta-seconds or HTTP-date), falling back to exponential
|
|
59
|
+
# backoff.
|
|
60
|
+
# * :max_retry_after upper bound (seconds) for a Retry-After delay;
|
|
61
|
+
# larger values fall back to the exponential backoff (default 60).
|
|
62
|
+
# * :retry_jitter randomize retry delays so many concurrent clients do not
|
|
63
|
+
# retry in lockstep (the thundering-herd problem). false (default)
|
|
64
|
+
# keeps the plain exponential backoff; :full draws uniformly from
|
|
65
|
+
# [0, backoff]; :equal from [backoff, 2 * backoff]; :decorrelated
|
|
66
|
+
# draws from [retry_interval, min(cap, previous * 3)] per the AWS
|
|
67
|
+
# "Exponential Backoff and Jitter" recipe. Jitter applies only to
|
|
68
|
+
# exponential backoff — a server-provided Retry-After delay is
|
|
69
|
+
# honored exactly as sent.
|
|
70
|
+
# * :retry_jitter_cap upper bound (seconds) for the :decorrelated jitter
|
|
71
|
+
# draw (default 30).
|
|
72
|
+
# * :retry_rng source of randomness for :retry_jitter — any object
|
|
73
|
+
# responding to #rand(range) (a seeded <tt>Random.new(seed)</tt>
|
|
74
|
+
# makes tests deterministic). Defaults to the Random class.
|
|
75
|
+
# * :breaker host-level circuit breaker, opt-in. true uses the defaults
|
|
76
|
+
# ({Contractful::CircuitBreaker::DEFAULT_THRESHOLD} consecutive
|
|
77
|
+
# failures, then fail fast for
|
|
78
|
+
# {Contractful::CircuitBreaker::DEFAULT_COOLDOWN} seconds); a Hash
|
|
79
|
+
# customizes it as <tt>breaker: { threshold: 5, cooldown: 30 }</tt>.
|
|
80
|
+
# Failures are network errors and retryable statuses (408/425/429/5xx);
|
|
81
|
+
# a request that ultimately fails after its retries counts once.
|
|
82
|
+
# While open, requests fail fast with
|
|
83
|
+
# {Contractful::CircuitOpenError}. State transitions post a `circuit`
|
|
84
|
+
# notification.
|
|
85
|
+
# * :pace proactive rate-limit enforcement (opt-in). true uses the
|
|
86
|
+
# defaults ({Contractful::Pacer::DEFAULT_LIMIT} requests per
|
|
87
|
+
# {Contractful::Pacer::DEFAULT_WINDOW}s per scheme/host/port across
|
|
88
|
+
# the process); a Hash customizes it:
|
|
89
|
+
# <tt>pace: { limit: 100, window: 60, max_wait: 10 }</tt>. When the
|
|
90
|
+
# window is full the request sleeps until a slot frees (posting a
|
|
91
|
+
# `pace` notification); a wait beyond `max_wait` raises
|
|
92
|
+
# {Contractful::PacingTimeout}. Retries acquire a slot per attempt.
|
|
93
|
+
# * :proxy An HTTP proxy URI to use for this request. Any value here
|
|
94
|
+
# (including nil) will override Contractful.proxy.
|
|
95
|
+
# * :persistent reuse the underlying connection across requests via the
|
|
96
|
+
# process-wide Contractful.connection_pool (HTTP keep-alive). Defaults to
|
|
97
|
+
# false. See Contractful::ConnectionPool.
|
|
98
|
+
# * :verify_ssl enable ssl verification, possible values are constants from
|
|
99
|
+
# OpenSSL::SSL::VERIFY_*, defaults to OpenSSL::SSL::VERIFY_PEER
|
|
100
|
+
# * :read_timeout, :write_timeout and :open_timeout are how long to wait
|
|
101
|
+
# to read from, write to, and open a connection, in seconds. Pass nil to
|
|
102
|
+
# disable the timeout.
|
|
103
|
+
# * :timeout can be used to set read and open timeouts together
|
|
104
|
+
# * :ssl_client_cert, :ssl_client_key, :ssl_ca_file, :ssl_ca_path,
|
|
105
|
+
# :ssl_cert_store, :ssl_verify_callback, :ssl_verify_callback_warnings,
|
|
106
|
+
# :ssl_min_version, :ssl_max_version
|
|
107
|
+
# * :ssl_version specifies the SSL version for the underlying Net::HTTP connection
|
|
108
|
+
# * :ssl_ciphers sets SSL ciphers for the connection. See
|
|
109
|
+
# OpenSSL::SSL::SSLContext#ciphers=
|
|
110
|
+
# * :before_execution_proc a Proc to call before executing the request. This
|
|
111
|
+
# proc, like procs from Contractful.before_execution_procs, will be
|
|
112
|
+
# called with the HTTP request and request params.
|
|
113
|
+
class Request
|
|
114
|
+
attr_reader :method, :uri, :url, :headers, :payload, :proxy,
|
|
115
|
+
:user, :password, :read_timeout, :write_timeout, :max_redirects,
|
|
116
|
+
:open_timeout, :raw_response, :processed_headers, :args,
|
|
117
|
+
:ssl_opts, :max_retries, :retry_interval, :persistent,
|
|
118
|
+
:retry_after, :max_retry_after,
|
|
119
|
+
:retry_jitter, :retry_jitter_cap, :retry_rng,
|
|
120
|
+
:breaker,
|
|
121
|
+
:pace
|
|
122
|
+
|
|
123
|
+
# An array of previous redirection responses
|
|
124
|
+
attr_accessor :redirection_history
|
|
125
|
+
|
|
126
|
+
def self.execute(args, & block)
|
|
127
|
+
new(args).execute(& block)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
SSL_OPTION_LIST = %w[client_cert client_key ca_file ca_path cert_store
|
|
131
|
+
version ciphers verify_callback verify_callback_warnings
|
|
132
|
+
min_version max_version]
|
|
133
|
+
|
|
134
|
+
# Maps user-friendly SSL version spellings to the integer constants
|
|
135
|
+
# accepted by OpenSSL::SSL::SSLContext#min_version=/max_version=. Both the
|
|
136
|
+
# "TLSv1_2" and "TLS1_2" spellings are normalized, since the exact symbol
|
|
137
|
+
# format accepted by OpenSSL has varied across OpenSSL gem releases.
|
|
138
|
+
# Integers (e.g. OpenSSL::SSL::TLS1_2_VERSION) and unknown values are
|
|
139
|
+
# passed through unchanged.
|
|
140
|
+
SSL_VERSION_CONSTANTS = {
|
|
141
|
+
'SSLV2' => 'SSL2_VERSION',
|
|
142
|
+
'SSLV3' => 'SSL3_VERSION',
|
|
143
|
+
'SSL2' => 'SSL2_VERSION',
|
|
144
|
+
'SSL3' => 'SSL3_VERSION',
|
|
145
|
+
'TLSV1' => 'TLS1_VERSION',
|
|
146
|
+
'TLSV1_1' => 'TLS1_1_VERSION',
|
|
147
|
+
'TLSV1_2' => 'TLS1_2_VERSION',
|
|
148
|
+
'TLSV1_3' => 'TLS1_3_VERSION',
|
|
149
|
+
'TLS1' => 'TLS1_VERSION',
|
|
150
|
+
'TLS1_1' => 'TLS1_1_VERSION',
|
|
151
|
+
'TLS1_2' => 'TLS1_2_VERSION',
|
|
152
|
+
'TLS1_3' => 'TLS1_3_VERSION'
|
|
153
|
+
}.freeze
|
|
154
|
+
|
|
155
|
+
# Request headers whose values are credentials. Their values are redacted
|
|
156
|
+
# from the request log (see docs/security/THREAT_MODEL.md T5 and
|
|
157
|
+
# docs/operations/OBSERVABILITY.md, which promise that auth headers are
|
|
158
|
+
# not logged).
|
|
159
|
+
REDACTED_HEADERS = %w[authorization proxy-authorization cookie].freeze
|
|
160
|
+
|
|
161
|
+
# Placeholder written to the log in place of a redacted header value.
|
|
162
|
+
REDACTED_VALUE = '[REDACTED]'
|
|
163
|
+
|
|
164
|
+
# Network-level failures that are considered transient. When the
|
|
165
|
+
# `:max_retries` request option is greater than zero, requests that fail
|
|
166
|
+
# with one of these errors are retried automatically (with exponential
|
|
167
|
+
# backoff). Retryable HTTP statuses (`Exceptions::RETRIABLE_STATUSES`)
|
|
168
|
+
# are retried by the pipeline as well; see #execute.
|
|
169
|
+
RETRIABLE_EXCEPTIONS = [
|
|
170
|
+
Contractful::Exceptions::Timeout, # includes Open/Read/WriteTimeout
|
|
171
|
+
Contractful::ServerBrokeConnection,
|
|
172
|
+
Errno::ECONNRESET,
|
|
173
|
+
Errno::ECONNABORTED,
|
|
174
|
+
Errno::EPIPE
|
|
175
|
+
].freeze
|
|
176
|
+
|
|
177
|
+
# Methods RFC 9110 §9.2.2 defines as idempotent: repeating one has the same
|
|
178
|
+
# effect as sending it once, so a retry cannot duplicate it. POST and PATCH
|
|
179
|
+
# are deliberately absent.
|
|
180
|
+
IDEMPOTENT_METHODS = %w[get head options trace put delete].freeze
|
|
181
|
+
|
|
182
|
+
def inspect
|
|
183
|
+
"<Contractful::Request @method=#{@method.inspect}, @url=#{redacted_url.inspect}>"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def initialize args
|
|
187
|
+
@method = normalize_method(args[:method])
|
|
188
|
+
@headers = (args[:headers] || {}).dup
|
|
189
|
+
raise ArgumentError, 'must pass :url' unless args[:url]
|
|
190
|
+
|
|
191
|
+
@url = process_url_params(normalize_url(args[:url]), headers)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@user = @password = nil
|
|
197
|
+
parse_url_with_auth!(url)
|
|
198
|
+
|
|
199
|
+
# process cookie arguments found in headers or args
|
|
200
|
+
@cookie_jar = process_cookie_args!(@uri, @headers, args)
|
|
201
|
+
|
|
202
|
+
@payload = Payload.generate(args[:payload])
|
|
203
|
+
|
|
204
|
+
@user = args[:user] if args.include?(:user)
|
|
205
|
+
@password = args[:password] if args.include?(:password)
|
|
206
|
+
|
|
207
|
+
if args.include?(:timeout)
|
|
208
|
+
@read_timeout = args[:timeout]
|
|
209
|
+
@open_timeout = args[:timeout]
|
|
210
|
+
end
|
|
211
|
+
@read_timeout = args[:read_timeout] if args.include?(:read_timeout)
|
|
212
|
+
@open_timeout = args[:open_timeout] if args.include?(:open_timeout)
|
|
213
|
+
@write_timeout = args[:write_timeout] if args.include?(:write_timeout)
|
|
214
|
+
@block_response = args[:block_response]
|
|
215
|
+
@raw_response = args[:raw_response] || false
|
|
216
|
+
@persistent = args.fetch(:persistent, false)
|
|
217
|
+
|
|
218
|
+
@stream_log_percent = args[:stream_log_percent] || 10
|
|
219
|
+
if @stream_log_percent <= 0 || @stream_log_percent > 100
|
|
220
|
+
raise ArgumentError.new(
|
|
221
|
+
"Invalid :stream_log_percent #{@stream_log_percent.inspect}"
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
@proxy = args.fetch(:proxy) if args.include?(:proxy)
|
|
226
|
+
|
|
227
|
+
@ssl_opts = {}
|
|
228
|
+
|
|
229
|
+
if args.include?(:verify_ssl)
|
|
230
|
+
v_ssl = args.fetch(:verify_ssl)
|
|
231
|
+
@ssl_opts[:verify_ssl] =
|
|
232
|
+
if v_ssl == true
|
|
233
|
+
# interpret :verify_ssl => true as VERIFY_PEER
|
|
234
|
+
OpenSSL::SSL::VERIFY_PEER
|
|
235
|
+
elsif v_ssl
|
|
236
|
+
# otherwise pass through any truthy values
|
|
237
|
+
v_ssl
|
|
238
|
+
else
|
|
239
|
+
# interpret all falsy :verify_ssl values as VERIFY_NONE
|
|
240
|
+
OpenSSL::SSL::VERIFY_NONE
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
# if :verify_ssl was not passed, default to VERIFY_PEER
|
|
244
|
+
@ssl_opts[:verify_ssl] = OpenSSL::SSL::VERIFY_PEER
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
SSL_OPTION_LIST.each do |key|
|
|
248
|
+
source_key = ('ssl_' + key).to_sym
|
|
249
|
+
@ssl_opts[key.to_sym] = args.fetch(source_key) if args.has_key?(source_key)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Set some other default SSL options, but only if we have an HTTPS URI.
|
|
253
|
+
# If there's no CA file, CA path, or cert store provided, use default
|
|
254
|
+
if use_ssl? && !ssl_ca_file && !ssl_ca_path && !@ssl_opts.include?(:cert_store)
|
|
255
|
+
@ssl_opts[:cert_store] = self.class.default_ssl_cert_store
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
@log = args[:log]
|
|
259
|
+
@max_redirects = args[:max_redirects] || 10
|
|
260
|
+
|
|
261
|
+
@max_retries = args.fetch(:max_retries, 0)
|
|
262
|
+
unless @max_retries.is_a?(Integer) && @max_retries >= 0
|
|
263
|
+
raise ArgumentError.new(
|
|
264
|
+
':max_retries must be a non-negative Integer, ' +
|
|
265
|
+
"got #{@max_retries.inspect}"
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
@idempotent = args.fetch(:idempotent, IDEMPOTENT_METHODS.include?(@method))
|
|
270
|
+
unless [true, false].include?(@idempotent)
|
|
271
|
+
raise ArgumentError.new(
|
|
272
|
+
':idempotent must be true or false, ' +
|
|
273
|
+
"got #{@idempotent.inspect}"
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
if @max_retries.positive? && !@idempotent
|
|
277
|
+
raise ArgumentError.new(
|
|
278
|
+
':max_retries requires an idempotent operation, but this ' +
|
|
279
|
+
"#{@method.upcase} request is not. A timed-out or 5xx request may " +
|
|
280
|
+
'already have been processed, so a retry can repeat it. Declare the ' +
|
|
281
|
+
'operation idempotent (idempotent: true on Request, `idempotent true` ' +
|
|
282
|
+
'on a contract) only if the server deduplicates requests, for example ' +
|
|
283
|
+
'with an Idempotency-Key header.'
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
@retry_interval = args.fetch(:retry_interval, 0.5)
|
|
288
|
+
unless @retry_interval.is_a?(Numeric) && @retry_interval >= 0
|
|
289
|
+
raise ArgumentError.new(
|
|
290
|
+
':retry_interval must be a non-negative Numeric, ' +
|
|
291
|
+
"got #{@retry_interval.inspect}"
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
@retry_jitter = args.fetch(:retry_jitter, false)
|
|
296
|
+
unless [false, :full, :equal, :decorrelated].include?(@retry_jitter)
|
|
297
|
+
raise ArgumentError.new(
|
|
298
|
+
':retry_jitter must be false, :full, :equal or :decorrelated, ' +
|
|
299
|
+
"got #{@retry_jitter.inspect}"
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
@retry_jitter_cap = args.fetch(:retry_jitter_cap, 30.0)
|
|
304
|
+
unless @retry_jitter_cap.is_a?(Numeric) && @retry_jitter_cap.positive?
|
|
305
|
+
raise ArgumentError.new(
|
|
306
|
+
':retry_jitter_cap must be a positive Numeric, ' +
|
|
307
|
+
"got #{@retry_jitter_cap.inspect}"
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
@retry_rng = args.fetch(:retry_rng, Random)
|
|
312
|
+
unless @retry_rng.respond_to?(:rand)
|
|
313
|
+
raise ArgumentError.new(
|
|
314
|
+
':retry_rng must respond to #rand(range), ' +
|
|
315
|
+
"got #{@retry_rng.inspect}"
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
@breaker = args.fetch(:breaker, false)
|
|
320
|
+
case @breaker
|
|
321
|
+
when false, true, Hash
|
|
322
|
+
# ok
|
|
323
|
+
else
|
|
324
|
+
raise ArgumentError.new(
|
|
325
|
+
':breaker must be true, false or a config Hash, ' +
|
|
326
|
+
"got #{@breaker.inspect}"
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
@pace = args.fetch(:pace, false)
|
|
331
|
+
case @pace
|
|
332
|
+
when false, true, Hash
|
|
333
|
+
# ok
|
|
334
|
+
else
|
|
335
|
+
raise ArgumentError.new(
|
|
336
|
+
':pace must be true, false or a config Hash, ' +
|
|
337
|
+
"got #{@pace.inspect}"
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
@retry_after = args.fetch(:retry_after, true) ? true : false
|
|
342
|
+
@max_retry_after = args.fetch(:max_retry_after, 60)
|
|
343
|
+
unless @max_retry_after.is_a?(Numeric) && @max_retry_after >= 0
|
|
344
|
+
raise ArgumentError.new(
|
|
345
|
+
':max_retry_after must be a non-negative Numeric, ' +
|
|
346
|
+
"got #{@max_retry_after.inspect}"
|
|
347
|
+
)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
@processed_headers = make_headers headers
|
|
351
|
+
@processed_headers_lowercase = @processed_headers.map do |k, v|
|
|
352
|
+
[k.downcase, v]
|
|
353
|
+
end.to_h
|
|
354
|
+
@args = args
|
|
355
|
+
|
|
356
|
+
@before_execution_proc = args[:before_execution_proc]
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def execute & block
|
|
360
|
+
# With 2.0.0+, net/http accepts URI objects in requests and handles wrapping
|
|
361
|
+
# IPv6 addresses in [] for use in the Host request header.
|
|
362
|
+
#
|
|
363
|
+
# The request is rebuilt on each attempt so that Net::HTTP mutations (for
|
|
364
|
+
# example basic auth headers) and consumed payload streams do not leak
|
|
365
|
+
# state between attempts.
|
|
366
|
+
attempts = 0
|
|
367
|
+
started = nil
|
|
368
|
+
event_url = redacted_url
|
|
369
|
+
response_for_attempt = nil
|
|
370
|
+
attempt_block =
|
|
371
|
+
if block
|
|
372
|
+
proc do |http_response, request, raw|
|
|
373
|
+
response_for_attempt = http_response
|
|
374
|
+
block.call(http_response, request, raw, &block)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
breaker = @breaker ? CircuitBreaker.registry.breaker_for(uri, **breaker_options) : nil
|
|
378
|
+
breaker&.check!
|
|
379
|
+
pacer = @pace ? Pacer.registry.pacer_for(uri, **pace_options) : nil
|
|
380
|
+
begin
|
|
381
|
+
loop do
|
|
382
|
+
req = net_http_request_class(method).new(uri, processed_headers)
|
|
383
|
+
pacer&.acquire
|
|
384
|
+
Notifications.post('request', method: method, url: event_url, attempt: attempts + 1)
|
|
385
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
386
|
+
response_for_attempt = nil
|
|
387
|
+
result = transmit uri, req, payload, & attempt_block
|
|
388
|
+
response = result.is_a?(Contractful::Response) ? result : response_for_attempt
|
|
389
|
+
Notifications.post(
|
|
390
|
+
'response', method: method, url: event_url,
|
|
391
|
+
status: response.respond_to?(:code) ? response.code : nil,
|
|
392
|
+
duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# When a block handles responses itself (error-status suppression),
|
|
396
|
+
# retryable statuses never raise — judge the returned response.
|
|
397
|
+
if block_given? && retry_after && attempts < max_retries &&
|
|
398
|
+
response.is_a?(Contractful::Response) &&
|
|
399
|
+
Exceptions.retryable?(response.code) && rewind_payload!
|
|
400
|
+
attempts += 1
|
|
401
|
+
delay = retry_delay_for_response(response, attempts)
|
|
402
|
+
Notifications.post('retry', method: method, url: event_url,
|
|
403
|
+
attempt: attempts, delay: delay, cause: response.code)
|
|
404
|
+
log_response_retry(response, attempts, delay)
|
|
405
|
+
sleep_for_retry(delay)
|
|
406
|
+
next
|
|
407
|
+
end
|
|
408
|
+
breaker&.record_response(response)
|
|
409
|
+
break result
|
|
410
|
+
end
|
|
411
|
+
rescue *RETRIABLE_EXCEPTIONS => e
|
|
412
|
+
attempts += 1
|
|
413
|
+
if attempts <= max_retries && rewind_payload!
|
|
414
|
+
delay = retry_backoff_delay(attempts)
|
|
415
|
+
Notifications.post('retry', method: method, url: event_url,
|
|
416
|
+
attempt: attempts, delay: delay, cause: e.class.name)
|
|
417
|
+
log_retry(e, attempts, delay)
|
|
418
|
+
sleep_for_retry(delay)
|
|
419
|
+
retry
|
|
420
|
+
end
|
|
421
|
+
breaker&.record_failure
|
|
422
|
+
raise
|
|
423
|
+
rescue Contractful::ExceptionWithResponse => e
|
|
424
|
+
# Error statuses raise inside transmit, so the response event for a
|
|
425
|
+
# failed attempt posts here rather than after the transmit call.
|
|
426
|
+
Notifications.post(
|
|
427
|
+
'response', method: method, url: event_url, status: e.http_code,
|
|
428
|
+
duration_ms: started && ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
429
|
+
)
|
|
430
|
+
attempts += 1
|
|
431
|
+
if retry_after && attempts <= max_retries && e.retryable? && rewind_payload!
|
|
432
|
+
delay = retry_delay_for_response(e.response, attempts)
|
|
433
|
+
Notifications.post('retry', method: method, url: event_url, attempt: attempts,
|
|
434
|
+
delay: delay,
|
|
435
|
+
cause: e.response ? e.http_code : e.class.name)
|
|
436
|
+
log_retry(e, attempts, delay)
|
|
437
|
+
sleep_for_retry(delay)
|
|
438
|
+
retry
|
|
439
|
+
end
|
|
440
|
+
breaker&.record_response(e.response) if e.response
|
|
441
|
+
breaker&.record_failure unless e.response
|
|
442
|
+
raise
|
|
443
|
+
end
|
|
444
|
+
ensure
|
|
445
|
+
payload&.close
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Exceptions that are retried automatically when `:max_retries` is used.
|
|
449
|
+
def self.retriable_exceptions
|
|
450
|
+
RETRIABLE_EXCEPTIONS
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# SSL-related options
|
|
454
|
+
def verify_ssl
|
|
455
|
+
@ssl_opts.fetch(:verify_ssl)
|
|
456
|
+
end
|
|
457
|
+
SSL_OPTION_LIST.each do |key|
|
|
458
|
+
define_method('ssl_' + key) do
|
|
459
|
+
@ssl_opts[key.to_sym]
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Return true if the request URI will use HTTPS.
|
|
464
|
+
#
|
|
465
|
+
# @return [Boolean]
|
|
466
|
+
#
|
|
467
|
+
def use_ssl?
|
|
468
|
+
uri.is_a?(URI::HTTPS)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Extract the query parameters and append them to the url
|
|
472
|
+
#
|
|
473
|
+
# Look through the headers hash for a :params option (case-insensitive,
|
|
474
|
+
# may be string or symbol). If present and the value is a Hash or
|
|
475
|
+
# Contractful::ParamsArray, *delete* the key/value pair from the headers
|
|
476
|
+
# hash and encode the value into a query string. Append this query string
|
|
477
|
+
# to the URL and return the resulting URL.
|
|
478
|
+
#
|
|
479
|
+
# @param [String] url
|
|
480
|
+
# @param [Hash] headers An options/headers hash to process. Mutation
|
|
481
|
+
# warning: the params key may be removed if present!
|
|
482
|
+
#
|
|
483
|
+
# @return [String] resulting url with query string
|
|
484
|
+
#
|
|
485
|
+
def process_url_params(url, headers)
|
|
486
|
+
url_params = nil
|
|
487
|
+
|
|
488
|
+
# find and extract/remove "params" key if the value is a Hash/ParamsArray
|
|
489
|
+
headers.delete_if do |key, value|
|
|
490
|
+
if key.to_s.downcase == 'params' &&
|
|
491
|
+
(value.is_a?(Hash) || value.is_a?(Contractful::ParamsArray))
|
|
492
|
+
raise ArgumentError.new("Multiple 'params' options passed") if url_params
|
|
493
|
+
|
|
494
|
+
url_params = value
|
|
495
|
+
true
|
|
496
|
+
else
|
|
497
|
+
false
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# build resulting URL with query string
|
|
502
|
+
if url_params && !url_params.empty?
|
|
503
|
+
query_string = Contractful::Utils.encode_query_string(url_params)
|
|
504
|
+
|
|
505
|
+
if url.include?('?')
|
|
506
|
+
url + '&' + query_string
|
|
507
|
+
else
|
|
508
|
+
url + '?' + query_string
|
|
509
|
+
end
|
|
510
|
+
else
|
|
511
|
+
url
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Render a hash of key => value pairs for cookies in the Request#cookie_jar
|
|
516
|
+
# that are valid for the Request#uri. This will not necessarily include all
|
|
517
|
+
# cookies if there are duplicate keys. It's safer to use the cookie_jar
|
|
518
|
+
# directly if that's a concern.
|
|
519
|
+
#
|
|
520
|
+
# @see Request#cookie_jar
|
|
521
|
+
#
|
|
522
|
+
# @return [Hash]
|
|
523
|
+
#
|
|
524
|
+
def cookies
|
|
525
|
+
hash = {}
|
|
526
|
+
|
|
527
|
+
@cookie_jar.cookies(uri).each do |c|
|
|
528
|
+
hash[c.name] = c.value
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
hash
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# @return [HTTP::CookieJar]
|
|
535
|
+
attr_reader :cookie_jar
|
|
536
|
+
|
|
537
|
+
# Render a Cookie HTTP request header from the contents of the @cookie_jar,
|
|
538
|
+
# or nil if the jar is empty.
|
|
539
|
+
#
|
|
540
|
+
# @see Request#cookie_jar
|
|
541
|
+
#
|
|
542
|
+
# @return [String, nil]
|
|
543
|
+
#
|
|
544
|
+
def make_cookie_header
|
|
545
|
+
return nil if cookie_jar.nil?
|
|
546
|
+
|
|
547
|
+
arr = cookie_jar.cookies(url)
|
|
548
|
+
return nil if arr.empty?
|
|
549
|
+
|
|
550
|
+
HTTP::Cookie.cookie_value(arr)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Process cookies passed as hash or as HTTP::CookieJar. For backwards
|
|
554
|
+
# compatibility, these may be passed as a :cookies option masquerading
|
|
555
|
+
# inside the headers hash. To avoid confusion, if :cookies is passed in
|
|
556
|
+
# both headers and Request#initialize, raise an error.
|
|
557
|
+
#
|
|
558
|
+
# :cookies may be a:
|
|
559
|
+
# - Hash{String/Symbol => String}
|
|
560
|
+
# - Array<HTTP::Cookie>
|
|
561
|
+
# - HTTP::CookieJar
|
|
562
|
+
#
|
|
563
|
+
# Passing as a hash:
|
|
564
|
+
# Keys may be symbols or strings. Values must be strings.
|
|
565
|
+
# Infer the domain name from the request URI and allow subdomains (as
|
|
566
|
+
# though '.example.com' had been set in a Set-Cookie header). Assume a
|
|
567
|
+
# path of '/'.
|
|
568
|
+
#
|
|
569
|
+
# Contractful::Request.new(url: 'http://example.com', method: :get,
|
|
570
|
+
# :cookies => {:foo => 'Value', 'bar' => '123'}
|
|
571
|
+
# )
|
|
572
|
+
#
|
|
573
|
+
# results in cookies as though set from the server by:
|
|
574
|
+
# Set-Cookie: foo=Value; Domain=.example.com; Path=/
|
|
575
|
+
# Set-Cookie: bar=123; Domain=.example.com; Path=/
|
|
576
|
+
#
|
|
577
|
+
# which yields a client cookie header of:
|
|
578
|
+
# Cookie: foo=Value; bar=123
|
|
579
|
+
#
|
|
580
|
+
# Passing as HTTP::CookieJar, which will be passed through directly:
|
|
581
|
+
#
|
|
582
|
+
# jar = HTTP::CookieJar.new
|
|
583
|
+
# jar.add(HTTP::Cookie.new('foo', 'Value', domain: 'example.com',
|
|
584
|
+
# path: '/', for_domain: false))
|
|
585
|
+
#
|
|
586
|
+
# Contractful::Request.new(..., :cookies => jar)
|
|
587
|
+
#
|
|
588
|
+
# @param [URI::HTTP] uri The URI for the request. This will be used to
|
|
589
|
+
# infer the domain name for cookies passed as strings in a hash. To avoid
|
|
590
|
+
# this implicit behavior, pass a full cookie jar or use HTTP::Cookie hash
|
|
591
|
+
# values.
|
|
592
|
+
# @param [Hash] headers The headers hash from which to pull the :cookies
|
|
593
|
+
# option. MUTATION NOTE: This key will be deleted from the hash if
|
|
594
|
+
# present.
|
|
595
|
+
# @param [Hash] args The options passed to Request#initialize. This hash
|
|
596
|
+
# will be used as another potential source for the :cookies key.
|
|
597
|
+
# These args will not be mutated.
|
|
598
|
+
#
|
|
599
|
+
# @return [HTTP::CookieJar] A cookie jar containing the parsed cookies.
|
|
600
|
+
#
|
|
601
|
+
def process_cookie_args!(uri, headers, args)
|
|
602
|
+
# Avoid ambiguity in whether options from headers or options from
|
|
603
|
+
# Request#initialize should take precedence by raising ArgumentError when
|
|
604
|
+
# both are present. Prior versions of contractful claimed to give
|
|
605
|
+
# precedence to init options, but actually gave precedence to headers.
|
|
606
|
+
# Avoid that mess by erroring out instead.
|
|
607
|
+
if headers[:cookies] && args[:cookies]
|
|
608
|
+
raise ArgumentError.new(
|
|
609
|
+
'Cannot pass :cookies in Request.new() and in headers hash'
|
|
610
|
+
)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
cookies_data = headers.delete(:cookies) || args[:cookies]
|
|
614
|
+
|
|
615
|
+
# return copy of cookie jar as is
|
|
616
|
+
return cookies_data.dup if cookies_data.is_a?(HTTP::CookieJar)
|
|
617
|
+
|
|
618
|
+
# convert cookies hash into a CookieJar
|
|
619
|
+
jar = HTTP::CookieJar.new
|
|
620
|
+
|
|
621
|
+
(cookies_data || []).each do |key, val|
|
|
622
|
+
# Support for Array<HTTP::Cookie> mode:
|
|
623
|
+
# If key is a cookie object, add it to the jar directly and assert that
|
|
624
|
+
# there is no separate val.
|
|
625
|
+
if key.is_a?(HTTP::Cookie)
|
|
626
|
+
raise ArgumentError.new("extra cookie val: #{val.inspect}") if val
|
|
627
|
+
|
|
628
|
+
jar.add(key)
|
|
629
|
+
next
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
key = key.to_s if key.is_a?(Symbol)
|
|
633
|
+
|
|
634
|
+
# assume implicit domain from the request URI, and set for_domain to
|
|
635
|
+
# permit subdomains
|
|
636
|
+
jar.add(HTTP::Cookie.new(key, val, domain: uri.hostname.downcase,
|
|
637
|
+
path: '/', for_domain: true))
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
jar
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Generate headers for use by a request. Header keys will be stringified
|
|
644
|
+
# using `#stringify_headers` to normalize them as capitalized strings.
|
|
645
|
+
#
|
|
646
|
+
# The final headers consist of:
|
|
647
|
+
# - default headers from #default_headers
|
|
648
|
+
# - user_headers provided here
|
|
649
|
+
# - headers from the payload object (e.g. Content-Type, Content-Lenth)
|
|
650
|
+
# - cookie headers from #make_cookie_header
|
|
651
|
+
#
|
|
652
|
+
# stringify_headers only normalizes Symbol keys to the canonical
|
|
653
|
+
# `Content-Type` style capitalization; String keys are passed through
|
|
654
|
+
# verbatim for backwards compatibility with rest-client 1.x/2.x
|
|
655
|
+
# (https://github.com/rest-client/rest-client/issues/599). Because
|
|
656
|
+
# equivalent header names may therefore be spelled with different
|
|
657
|
+
# casing, the merges below are case-insensitive: a later header whose
|
|
658
|
+
# name matches an earlier one case-insensitively replaces it (keeping
|
|
659
|
+
# the casing of the later name) instead of coexisting with it. This both
|
|
660
|
+
# keeps the accidental-override warnings working across casings and
|
|
661
|
+
# prevents duplicate headers from reaching the wire.
|
|
662
|
+
#
|
|
663
|
+
# @param [Hash] user_headers User-provided headers to include
|
|
664
|
+
#
|
|
665
|
+
# @return [Hash<String, String>] A hash of HTTP headers => values
|
|
666
|
+
#
|
|
667
|
+
def make_headers(user_headers)
|
|
668
|
+
headers = merge_headers(stringify_headers(default_headers),
|
|
669
|
+
stringify_headers(user_headers))
|
|
670
|
+
|
|
671
|
+
# override headers from the payload (e.g. Content-Type, Content-Length)
|
|
672
|
+
if @payload
|
|
673
|
+
payload_headers = @payload.headers
|
|
674
|
+
|
|
675
|
+
# Warn the user if we override any headers that were previously
|
|
676
|
+
# present. This usually indicates that contractful was passed
|
|
677
|
+
# conflicting information, e.g. if it was asked to render a payload as
|
|
678
|
+
# x-www-form-urlencoded but a Content-Type application/json was
|
|
679
|
+
# also supplied by the user.
|
|
680
|
+
payload_headers.each_pair do |key, val|
|
|
681
|
+
next unless (existing = header_key_for(headers, key)) && headers[existing] != val
|
|
682
|
+
|
|
683
|
+
warn("warning: Overriding #{key.inspect} header " +
|
|
684
|
+
"#{headers[existing].inspect} with #{val.inspect} " +
|
|
685
|
+
'due to payload')
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
headers = merge_headers(headers, payload_headers)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# merge in cookies
|
|
692
|
+
cookies = make_cookie_header
|
|
693
|
+
if cookies && !cookies.empty?
|
|
694
|
+
warn('warning: overriding "Cookie" header with :cookies option') if header_key_for(headers, 'Cookie')
|
|
695
|
+
headers = merge_headers(headers, { 'Cookie' => cookies })
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
headers
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# The proxy URI for this request. If `:proxy` was provided on this request,
|
|
702
|
+
# use it over `Contractful.proxy`.
|
|
703
|
+
#
|
|
704
|
+
# Return false if a proxy was explicitly set and is falsy.
|
|
705
|
+
#
|
|
706
|
+
# @return [URI, false, nil]
|
|
707
|
+
#
|
|
708
|
+
def proxy_uri
|
|
709
|
+
if defined?(@proxy)
|
|
710
|
+
if @proxy
|
|
711
|
+
URI.parse(@proxy)
|
|
712
|
+
else
|
|
713
|
+
false
|
|
714
|
+
end
|
|
715
|
+
elsif Contractful.proxy_set?
|
|
716
|
+
if Contractful.proxy
|
|
717
|
+
URI.parse(Contractful.proxy)
|
|
718
|
+
else
|
|
719
|
+
false
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def net_http_object(hostname, port)
|
|
725
|
+
p_uri = proxy_uri
|
|
726
|
+
|
|
727
|
+
if p_uri.nil?
|
|
728
|
+
# no proxy set
|
|
729
|
+
Net::HTTP.new(hostname, port)
|
|
730
|
+
elsif !p_uri
|
|
731
|
+
# proxy explicitly set to none
|
|
732
|
+
Net::HTTP.new(hostname, port, nil, nil, nil, nil)
|
|
733
|
+
else
|
|
734
|
+
Net::HTTP.new(hostname, port,
|
|
735
|
+
p_uri.hostname, p_uri.port, p_uri.user, p_uri.password)
|
|
736
|
+
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def net_http_request_class(method)
|
|
741
|
+
Net::HTTP.const_get(method.capitalize, false)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def net_http_do_request(http, req, body = nil, &block)
|
|
745
|
+
return http.request(req, body, &block) unless body&.respond_to?(:read)
|
|
746
|
+
|
|
747
|
+
req.body_stream = body
|
|
748
|
+
http.request(req, nil, &block)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# Normalize a URL by adding a protocol if none is present.
|
|
755
|
+
#
|
|
756
|
+
# If the string has no HTTP-like scheme (i.e. scheme followed by '//'), a
|
|
757
|
+
# scheme of 'http' will be added. This mimics the behavior of browsers and
|
|
758
|
+
# user agents like cURL.
|
|
759
|
+
#
|
|
760
|
+
# @param [String] url A URL string.
|
|
761
|
+
#
|
|
762
|
+
# @return [String]
|
|
763
|
+
#
|
|
764
|
+
def normalize_url(url)
|
|
765
|
+
url = 'http://' + url unless url.match(%r{\A[a-z][a-z0-9+.-]*://}i)
|
|
766
|
+
url
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# Return a certificate store that can be used to validate certificates with
|
|
770
|
+
# the system certificate authorities. This will probably not do anything on
|
|
771
|
+
# OS X, which monkey patches OpenSSL in terrible ways to insert its own
|
|
772
|
+
# validation. On most *nix platforms, this will add the system certifcates
|
|
773
|
+
# using OpenSSL::X509::Store#set_default_paths. On Windows, this will use
|
|
774
|
+
# Contractful::Windows::RootCerts to look up the CAs trusted by the system.
|
|
775
|
+
#
|
|
776
|
+
# The store is memoized as a class instance variable. The previous
|
|
777
|
+
# implementation rebuilt a fresh OpenSSL::X509::Store on every request
|
|
778
|
+
# that did not pass ssl_ca_file / ssl_ca_path / cert_store explicitly,
|
|
779
|
+
# and on Windows that re-loaded every trusted root certificate via FFI
|
|
780
|
+
# (hundreds of FFI calls + cert parses per request). The Windows cert
|
|
781
|
+
# store is process-stable; reusing it is a major perf win on Windows
|
|
782
|
+
# and a no-op on Linux (set_default_paths is cheap there too).
|
|
783
|
+
#
|
|
784
|
+
# Tests that want a fresh store can call
|
|
785
|
+
# Contractful::Request.reset_default_ssl_cert_store!
|
|
786
|
+
#
|
|
787
|
+
# @return [OpenSSL::X509::Store]
|
|
788
|
+
#
|
|
789
|
+
@default_ssl_cert_store_mutex = Mutex.new
|
|
790
|
+
|
|
791
|
+
def self.default_ssl_cert_store
|
|
792
|
+
return @default_ssl_cert_store if @default_ssl_cert_store
|
|
793
|
+
|
|
794
|
+
@default_ssl_cert_store_mutex.synchronize do
|
|
795
|
+
return @default_ssl_cert_store if @default_ssl_cert_store
|
|
796
|
+
|
|
797
|
+
@default_ssl_cert_store = build_default_ssl_cert_store
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Forget the memoized default cert store. Tests that fork the
|
|
802
|
+
# process or want to re-pick up changes to the Windows root
|
|
803
|
+
# certificate set call this between scenarios.
|
|
804
|
+
def self.reset_default_ssl_cert_store!
|
|
805
|
+
@default_ssl_cert_store_mutex.synchronize { @default_ssl_cert_store = nil }
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def self.build_default_ssl_cert_store
|
|
809
|
+
cert_store = OpenSSL::X509::Store.new
|
|
810
|
+
cert_store.set_default_paths
|
|
811
|
+
|
|
812
|
+
# set_default_paths() doesn't do anything on Windows, so look up
|
|
813
|
+
# certificates using the win32 API.
|
|
814
|
+
if Contractful::Platform.windows?
|
|
815
|
+
Contractful::Windows::RootCerts.instance.to_a.uniq.each do |cert|
|
|
816
|
+
cert_store.add_cert(cert)
|
|
817
|
+
rescue OpenSSL::X509::StoreError => e
|
|
818
|
+
# ignore duplicate certs
|
|
819
|
+
raise unless e.message == 'cert already in hash table'
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
cert_store
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def redacted_uri
|
|
827
|
+
return unless uri
|
|
828
|
+
|
|
829
|
+
if uri.password
|
|
830
|
+
sanitized_uri = uri.dup
|
|
831
|
+
sanitized_uri.password = 'REDACTED'
|
|
832
|
+
sanitized_uri
|
|
833
|
+
else
|
|
834
|
+
uri
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def redacted_url
|
|
839
|
+
redacted_uri.to_s
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Default to the global logger if there's not a request-specific one
|
|
843
|
+
def log
|
|
844
|
+
@log || Contractful.log
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def log_request
|
|
848
|
+
return unless log
|
|
849
|
+
|
|
850
|
+
out = []
|
|
851
|
+
|
|
852
|
+
out << "Contractful.#{method} #{redacted_url.inspect}"
|
|
853
|
+
out << payload.short_inspect if payload
|
|
854
|
+
out << processed_headers.to_a.sort.map { |(k, v)|
|
|
855
|
+
[k.inspect, log_header_value(k, v).inspect].join('=>')
|
|
856
|
+
}.join(', ')
|
|
857
|
+
log << (out.join(', ') + "\n")
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
# Value to render for a header in the request log. Credential-bearing
|
|
861
|
+
# headers (Authorization, Proxy-Authorization, Cookie) are redacted so
|
|
862
|
+
# that enabling logging cannot leak tokens or session cookies.
|
|
863
|
+
def log_header_value(key, value)
|
|
864
|
+
if REDACTED_HEADERS.include?(key.to_s.downcase)
|
|
865
|
+
REDACTED_VALUE
|
|
866
|
+
else
|
|
867
|
+
value
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
private :log_header_value
|
|
871
|
+
|
|
872
|
+
# Return a hash of headers whose keys are capitalized strings
|
|
873
|
+
#
|
|
874
|
+
# Design note: only Symbol keys are normalized to `Content-Type` style
|
|
875
|
+
# capitalization. String keys are passed through verbatim, matching
|
|
876
|
+
# rest-client 1.x/2.x behavior
|
|
877
|
+
# (https://github.com/rest-client/rest-client/issues/599). String keys
|
|
878
|
+
# are therefore not canonicalized (e.g. 'x-custom-header' stays
|
|
879
|
+
# lowercase on the wire), which some clients depend on; equivalent
|
|
880
|
+
# header names that differ in casing are treated as the same header at
|
|
881
|
+
# merge time by #make_headers/#merge_headers.
|
|
882
|
+
#
|
|
883
|
+
def stringify_headers headers
|
|
884
|
+
headers.each_with_object({}) do |(key, value), result|
|
|
885
|
+
key = key.to_s.split('_').map(&:capitalize).join('-') if key.is_a? Symbol
|
|
886
|
+
if 'CONTENT-TYPE' == key.upcase
|
|
887
|
+
result[key] = maybe_convert_extension(value.to_s)
|
|
888
|
+
elsif 'ACCEPT' == key.upcase
|
|
889
|
+
# Accept can be composed of several comma-separated values
|
|
890
|
+
if value.is_a? Array
|
|
891
|
+
target_values = value
|
|
892
|
+
else
|
|
893
|
+
target_values = value.to_s.split ','
|
|
894
|
+
end
|
|
895
|
+
result[key] = target_values.map do |ext|
|
|
896
|
+
maybe_convert_extension(ext.to_s.strip)
|
|
897
|
+
end.join(', ')
|
|
898
|
+
else
|
|
899
|
+
result[key] = value.to_s
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# Return the key in `headers` that equals `key` case-insensitively, or
|
|
905
|
+
# nil if there is no match. HTTP header names are case-insensitive
|
|
906
|
+
# (RFC 7230), and because stringify_headers passes String keys through
|
|
907
|
+
# verbatim, equivalent names may be spelled with different casing.
|
|
908
|
+
#
|
|
909
|
+
# @return [String, nil] the matching key as present in `headers`
|
|
910
|
+
#
|
|
911
|
+
def header_key_for(headers, key)
|
|
912
|
+
headers.each_key do |existing|
|
|
913
|
+
return existing if existing.casecmp?(key)
|
|
914
|
+
end
|
|
915
|
+
nil
|
|
916
|
+
end
|
|
917
|
+
private :header_key_for
|
|
918
|
+
|
|
919
|
+
# Merge two header hashes, treating keys that differ only in case as the
|
|
920
|
+
# same header: entries from `later` replace entries from `earlier`,
|
|
921
|
+
# keeping the casing of the later key. This prevents, for example, a
|
|
922
|
+
# user-supplied 'content-type' from coexisting with the default
|
|
923
|
+
# 'Accept'-style capitalized headers and producing a duplicate header on
|
|
924
|
+
# the wire.
|
|
925
|
+
#
|
|
926
|
+
# @param [Hash] earlier headers merged in first
|
|
927
|
+
# @param [Hash] later headers that win on (case-insensitive) collision
|
|
928
|
+
#
|
|
929
|
+
# @return [Hash]
|
|
930
|
+
#
|
|
931
|
+
def merge_headers(earlier, later)
|
|
932
|
+
merged = earlier.dup
|
|
933
|
+
later.each do |key, val|
|
|
934
|
+
if (existing = header_key_for(merged, key)) && !existing.eql?(key)
|
|
935
|
+
merged.delete(existing)
|
|
936
|
+
end
|
|
937
|
+
merged[key] = val
|
|
938
|
+
end
|
|
939
|
+
merged
|
|
940
|
+
end
|
|
941
|
+
private :merge_headers
|
|
942
|
+
|
|
943
|
+
# Default headers set by Contractful. In addition to these headers, servers
|
|
944
|
+
# will receive headers set by Net::HTTP, such as Accept-Encoding and Host.
|
|
945
|
+
#
|
|
946
|
+
# @return [Hash<Symbol, String>]
|
|
947
|
+
def default_headers
|
|
948
|
+
{
|
|
949
|
+
accept: '*/*',
|
|
950
|
+
user_agent: Contractful::Platform.default_user_agent
|
|
951
|
+
}
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
private
|
|
955
|
+
|
|
956
|
+
# Rewind the payload so that a retried request can send the body again.
|
|
957
|
+
# Returns true when there is no payload or it was successfully rewound,
|
|
958
|
+
# false when the payload cannot be rewound (e.g. a non-rewindable request
|
|
959
|
+
# stream), in which case retrying would resend a truncated or empty body.
|
|
960
|
+
def rewind_payload!
|
|
961
|
+
return true unless payload
|
|
962
|
+
|
|
963
|
+
payload.rewind
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Exponential backoff between retries: `:retry_interval` for the first
|
|
967
|
+
# retry, then doubling for each subsequent attempt.
|
|
968
|
+
def retry_backoff_delay(attempt)
|
|
969
|
+
delay = retry_interval * (2**(attempt - 1))
|
|
970
|
+
apply_retry_jitter(delay, attempt)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# Randomize an exponential backoff per the configured jitter style.
|
|
974
|
+
# Called only for computed backoff — a server's Retry-After delay is
|
|
975
|
+
# honored exactly, so many clients still retry in unison only when the
|
|
976
|
+
# server itself asked them to.
|
|
977
|
+
def apply_retry_jitter(delay, attempt)
|
|
978
|
+
return delay if @retry_jitter == false || delay.zero?
|
|
979
|
+
|
|
980
|
+
case @retry_jitter
|
|
981
|
+
when :full then @retry_rng.rand(0..delay)
|
|
982
|
+
when :equal then delay + @retry_rng.rand(0..delay)
|
|
983
|
+
when :decorrelated then decorrelated_jitter_delay(attempt)
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Normalize the :pace option into registry kwargs.
|
|
988
|
+
def pace_options
|
|
989
|
+
case @pace
|
|
990
|
+
when Hash
|
|
991
|
+
{ limit: @pace.fetch(:limit, Pacer::DEFAULT_LIMIT),
|
|
992
|
+
window: @pace.fetch(:window, Pacer::DEFAULT_WINDOW),
|
|
993
|
+
max_wait: @pace.fetch(:max_wait, Pacer::DEFAULT_MAX_WAIT) }
|
|
994
|
+
else
|
|
995
|
+
{ limit: Pacer::DEFAULT_LIMIT, window: Pacer::DEFAULT_WINDOW,
|
|
996
|
+
max_wait: Pacer::DEFAULT_MAX_WAIT }
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
# Normalize the :breaker option into registry kwargs.
|
|
1001
|
+
def breaker_options
|
|
1002
|
+
case @breaker
|
|
1003
|
+
when Hash
|
|
1004
|
+
{ threshold: @breaker.fetch(:threshold, CircuitBreaker::DEFAULT_THRESHOLD),
|
|
1005
|
+
cooldown: @breaker.fetch(:cooldown, CircuitBreaker::DEFAULT_COOLDOWN) }
|
|
1006
|
+
else
|
|
1007
|
+
{ threshold: CircuitBreaker::DEFAULT_THRESHOLD,
|
|
1008
|
+
cooldown: CircuitBreaker::DEFAULT_COOLDOWN }
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# AWS "Exponential Backoff and Jitter" decorrelated draw: each attempt
|
|
1013
|
+
# samples [retry_interval, min(cap, previous * 3)], where the previous
|
|
1014
|
+
# value is the raw (unjittered) backoff of the preceding attempt —
|
|
1015
|
+
# stateless per request, deterministic under a seeded :retry_rng.
|
|
1016
|
+
def decorrelated_jitter_delay(attempt)
|
|
1017
|
+
base = retry_interval
|
|
1018
|
+
previous = attempt <= 1 ? base : base * (2**(attempt - 2))
|
|
1019
|
+
draw = @retry_rng.rand(base..[previous * 3, @retry_jitter_cap].min)
|
|
1020
|
+
[draw, @retry_jitter_cap].min
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def sleep_for_retry(secs)
|
|
1024
|
+
sleep(secs) if secs.positive?
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Delay before retrying after a retryable HTTP response: honor the
|
|
1028
|
+
# server's Retry-After header (seconds or HTTP-date) when present and
|
|
1029
|
+
# within :max_retry_after; on a 429 without Retry-After, fall back to
|
|
1030
|
+
# the RateLimit-Reset quota window; otherwise exponential backoff.
|
|
1031
|
+
def retry_delay_for_response(response, attempt)
|
|
1032
|
+
header = response.headers[:retry_after] if response.respond_to?(:headers)
|
|
1033
|
+
delay = parse_retry_after(header)
|
|
1034
|
+
|
|
1035
|
+
if delay.nil? && response.respond_to?(:rate_limit) && response.code == 429
|
|
1036
|
+
delay = rate_limit_reset_delay(response)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
if delay.nil? || delay > max_retry_after
|
|
1040
|
+
retry_backoff_delay(attempt)
|
|
1041
|
+
else
|
|
1042
|
+
delay
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# Seconds until the reported rate-limit quota resets, when the server
|
|
1047
|
+
# sent RateLimit-Reset (or the legacy X-RateLimit-Reset).
|
|
1048
|
+
def rate_limit_reset_delay(response)
|
|
1049
|
+
info = response.rate_limit
|
|
1050
|
+
return nil unless info&.reset
|
|
1051
|
+
|
|
1052
|
+
secs = info.reset_in
|
|
1053
|
+
secs&.positive? ? secs : nil
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# Retry-After: delta-seconds ("120") or HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
|
|
1057
|
+
def parse_retry_after(value)
|
|
1058
|
+
return nil if value.nil?
|
|
1059
|
+
|
|
1060
|
+
if value.match?(/\A\d+\z/)
|
|
1061
|
+
value.to_f
|
|
1062
|
+
else
|
|
1063
|
+
require 'time'
|
|
1064
|
+
delay = Time.httpdate(value) - Time.now
|
|
1065
|
+
delay.positive? ? delay : 0.0
|
|
1066
|
+
end
|
|
1067
|
+
rescue StandardError
|
|
1068
|
+
nil
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def log_response_retry(response, attempt, delay)
|
|
1072
|
+
return unless log
|
|
1073
|
+
|
|
1074
|
+
log << ("Contractful retry ##{attempt}/#{max_retries} for " +
|
|
1075
|
+
"#{redacted_url.inspect} after response HTTP #{response.code}: " +
|
|
1076
|
+
"sleeping #{delay}s\n")
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def log_retry(err, attempt, delay)
|
|
1080
|
+
return unless log
|
|
1081
|
+
|
|
1082
|
+
log << ("Contractful retry ##{attempt}/#{max_retries} for " +
|
|
1083
|
+
"#{redacted_url.inspect} after #{err.class}: " +
|
|
1084
|
+
"sleeping #{delay}s\n")
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
# Parse the `@url` string into a URI object and save it as
|
|
1088
|
+
# `@uri`. Also save any basic auth user or password as @user and @password.
|
|
1089
|
+
# If no auth info was passed, check for credentials in a Netrc file.
|
|
1090
|
+
#
|
|
1091
|
+
# @param [String] url A URL string.
|
|
1092
|
+
#
|
|
1093
|
+
# @return [URI]
|
|
1094
|
+
#
|
|
1095
|
+
# @raise URI::InvalidURIError on invalid URIs
|
|
1096
|
+
#
|
|
1097
|
+
def parse_url_with_auth!(url)
|
|
1098
|
+
uri = URI.parse(url)
|
|
1099
|
+
|
|
1100
|
+
# Ruby >= 3.4 returns an empty hostname (rather than nil) for URIs
|
|
1101
|
+
# such as 'http:///'.
|
|
1102
|
+
raise URI::InvalidURIError.new("bad URI(no host provided): #{url}") if uri.hostname.nil? || uri.hostname.empty?
|
|
1103
|
+
|
|
1104
|
+
@user = CGI.unescape(uri.user) if uri.user
|
|
1105
|
+
@password = CGI.unescape(uri.password) if uri.password
|
|
1106
|
+
@user, @password = Netrc.read[uri.hostname] if !@user && !@password
|
|
1107
|
+
|
|
1108
|
+
@uri = uri
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
def print_verify_callback_warnings
|
|
1112
|
+
warned = false
|
|
1113
|
+
if Contractful::Platform.mac_mri?
|
|
1114
|
+
warn('warning: ssl_verify_callback return code is ignored on OS X')
|
|
1115
|
+
warned = true
|
|
1116
|
+
end
|
|
1117
|
+
if Contractful::Platform.jruby?
|
|
1118
|
+
warn('warning: SSL verify_callback may not work correctly in jruby')
|
|
1119
|
+
warn('see https://github.com/jruby/jruby/issues/597')
|
|
1120
|
+
warned = true
|
|
1121
|
+
end
|
|
1122
|
+
warned
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# Parse a method and return a normalized string version.
|
|
1126
|
+
#
|
|
1127
|
+
# Raise ArgumentError if the method is falsy, but otherwise do no
|
|
1128
|
+
# validation.
|
|
1129
|
+
#
|
|
1130
|
+
# @param method [String, Symbol]
|
|
1131
|
+
#
|
|
1132
|
+
# @return [String]
|
|
1133
|
+
#
|
|
1134
|
+
# @see net_http_request_class
|
|
1135
|
+
#
|
|
1136
|
+
def normalize_method(method)
|
|
1137
|
+
raise ArgumentError.new('must pass :method') unless method
|
|
1138
|
+
|
|
1139
|
+
method.to_s.downcase
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def transmit uri, req, payload, & block
|
|
1143
|
+
# We set this to true in the net/http block so that we can distinguish
|
|
1144
|
+
# read_timeout from open_timeout. Now that we only support Ruby 2.0+,
|
|
1145
|
+
# this is only needed for Timeout exceptions thrown outside of Net::HTTP.
|
|
1146
|
+
established_connection = false
|
|
1147
|
+
|
|
1148
|
+
setup_credentials req
|
|
1149
|
+
|
|
1150
|
+
net = net_http_object(uri.hostname, uri.port)
|
|
1151
|
+
configure_net_http(net, uri)
|
|
1152
|
+
|
|
1153
|
+
Contractful.before_execution_procs.each do |before_proc|
|
|
1154
|
+
before_proc.call(req, args)
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
@before_execution_proc.call(req, args) if @before_execution_proc
|
|
1158
|
+
|
|
1159
|
+
log_request
|
|
1160
|
+
|
|
1161
|
+
start_time = Time.now
|
|
1162
|
+
tempfile = nil
|
|
1163
|
+
|
|
1164
|
+
if persistent
|
|
1165
|
+
pool = Contractful.connection_pool
|
|
1166
|
+
key = persistent_connection_key(uri)
|
|
1167
|
+
|
|
1168
|
+
http = pool.checkout(key) { net.start }
|
|
1169
|
+
# The pooled connection may have been built by a request with different
|
|
1170
|
+
# timeout settings; always (re)apply this request's timeouts.
|
|
1171
|
+
apply_timeouts!(http)
|
|
1172
|
+
|
|
1173
|
+
begin
|
|
1174
|
+
established_connection = true
|
|
1175
|
+
res, tempfile = perform_net_http_request(http, req, payload)
|
|
1176
|
+
rescue ::Exception
|
|
1177
|
+
# The connection state cannot be trusted after any failure; never
|
|
1178
|
+
# return it to the pool.
|
|
1179
|
+
pool.invalidate(key, http)
|
|
1180
|
+
raise
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
pool.checkin(key, http)
|
|
1184
|
+
|
|
1185
|
+
# Process the result only after the connection has been returned to
|
|
1186
|
+
# the pool: process_result may raise for HTTP error responses, and
|
|
1187
|
+
# those leave the connection perfectly reusable.
|
|
1188
|
+
process_result(res, start_time, tempfile, &block) unless @block_response
|
|
1189
|
+
else
|
|
1190
|
+
net.start do |http|
|
|
1191
|
+
established_connection = true
|
|
1192
|
+
|
|
1193
|
+
res, tempfile = perform_net_http_request(http, req, payload)
|
|
1194
|
+
process_result(res, start_time, tempfile, &block) unless @block_response
|
|
1195
|
+
end
|
|
1196
|
+
end
|
|
1197
|
+
rescue EOFError
|
|
1198
|
+
raise Contractful::ServerBrokeConnection
|
|
1199
|
+
rescue Net::OpenTimeout => e
|
|
1200
|
+
raise Contractful::Exceptions::OpenTimeout.new(nil, e)
|
|
1201
|
+
rescue Net::ReadTimeout => e
|
|
1202
|
+
raise Contractful::Exceptions::ReadTimeout.new(nil, e)
|
|
1203
|
+
rescue Net::WriteTimeout => e
|
|
1204
|
+
raise Contractful::Exceptions::WriteTimeout.new(nil, e)
|
|
1205
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => e
|
|
1206
|
+
# handling for non-Net::HTTP timeouts
|
|
1207
|
+
raise Contractful::Exceptions::ReadTimeout.new(nil, e) if established_connection
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
raise Contractful::Exceptions::OpenTimeout.new(nil, e)
|
|
1211
|
+
|
|
1212
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
1213
|
+
# TODO: deprecate and remove Contractful::SSLCertificateNotVerified and just
|
|
1214
|
+
# pass through OpenSSL::SSL::SSLError directly.
|
|
1215
|
+
#
|
|
1216
|
+
# Exceptions in verify_callback are ignored [1], and jruby doesn't support
|
|
1217
|
+
# it at all [2]. Contractful has to catch OpenSSL::SSL::SSLError and either
|
|
1218
|
+
# re-throw it as is, or throw SSLCertificateNotVerified based on the
|
|
1219
|
+
# contents of the message field of the original exception.
|
|
1220
|
+
#
|
|
1221
|
+
# The client has to handle OpenSSL::SSL::SSLError exceptions anyway, so
|
|
1222
|
+
# we shouldn't make them handle both OpenSSL and Contractful exceptions.
|
|
1223
|
+
#
|
|
1224
|
+
# [1] https://github.com/ruby/ruby/blob/89e70fe8e7/ext/openssl/ossl.c#L238
|
|
1225
|
+
# [2] https://github.com/jruby/jruby/issues/597
|
|
1226
|
+
|
|
1227
|
+
raise SSLCertificateNotVerified.new(e.message) if e.message.include?('certificate verify failed')
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
raise e
|
|
1231
|
+
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
# Apply the request's SSL settings and timeouts to a Net::HTTP object.
|
|
1235
|
+
def configure_net_http(net, uri)
|
|
1236
|
+
net.use_ssl = uri.is_a?(URI::HTTPS)
|
|
1237
|
+
net.ssl_version = ssl_version if ssl_version
|
|
1238
|
+
net.min_version = normalize_ssl_version(ssl_min_version) if ssl_min_version
|
|
1239
|
+
net.max_version = normalize_ssl_version(ssl_max_version) if ssl_max_version
|
|
1240
|
+
net.ciphers = ssl_ciphers if ssl_ciphers
|
|
1241
|
+
|
|
1242
|
+
net.verify_mode = verify_ssl
|
|
1243
|
+
|
|
1244
|
+
net.cert = ssl_client_cert if ssl_client_cert
|
|
1245
|
+
net.key = ssl_client_key if ssl_client_key
|
|
1246
|
+
net.ca_file = ssl_ca_file if ssl_ca_file
|
|
1247
|
+
net.ca_path = ssl_ca_path if ssl_ca_path
|
|
1248
|
+
net.cert_store = ssl_cert_store if ssl_cert_store
|
|
1249
|
+
|
|
1250
|
+
# We no longer rely on net.verify_callback for the main SSL verification
|
|
1251
|
+
# because it's not well supported on all platforms (see comments below).
|
|
1252
|
+
# But do allow users to set one if they want.
|
|
1253
|
+
if ssl_verify_callback
|
|
1254
|
+
net.verify_callback = ssl_verify_callback
|
|
1255
|
+
|
|
1256
|
+
# Hilariously, jruby only calls the callback when cert_store is set to
|
|
1257
|
+
# something, so make sure to set one.
|
|
1258
|
+
# https://github.com/jruby/jruby/issues/597
|
|
1259
|
+
net.cert_store ||= OpenSSL::X509::Store.new if Contractful::Platform.jruby?
|
|
1260
|
+
|
|
1261
|
+
if (ssl_verify_callback_warnings != false) && print_verify_callback_warnings
|
|
1262
|
+
warn('pass :ssl_verify_callback_warnings => false to silence this')
|
|
1263
|
+
end
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE
|
|
1267
|
+
warn('WARNING: OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE')
|
|
1268
|
+
warn('This dangerous monkey patch leaves you open to MITM attacks!')
|
|
1269
|
+
warn('Try passing :verify_ssl => false instead.')
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
apply_timeouts!(net)
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
# Apply the request's timeout settings to a Net::HTTP object. Safe to call
|
|
1276
|
+
# multiple times and on already-started connections, which is how pooled
|
|
1277
|
+
# connections pick up the timeouts of each request that reuses them.
|
|
1278
|
+
def apply_timeouts!(net)
|
|
1279
|
+
if defined? @read_timeout
|
|
1280
|
+
@read_timeout = normalize_timeout_value!(@read_timeout)
|
|
1281
|
+
net.read_timeout = @read_timeout
|
|
1282
|
+
end
|
|
1283
|
+
if defined? @open_timeout
|
|
1284
|
+
@open_timeout = normalize_timeout_value!(@open_timeout)
|
|
1285
|
+
net.open_timeout = @open_timeout
|
|
1286
|
+
end
|
|
1287
|
+
return unless defined? @write_timeout
|
|
1288
|
+
|
|
1289
|
+
@write_timeout = normalize_timeout_value!(@write_timeout)
|
|
1290
|
+
net.write_timeout = @write_timeout
|
|
1291
|
+
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
def normalize_timeout_value!(value)
|
|
1295
|
+
if value == -1
|
|
1296
|
+
warn 'Deprecated: to disable timeouts, please use nil instead of -1'
|
|
1297
|
+
nil
|
|
1298
|
+
else
|
|
1299
|
+
value
|
|
1300
|
+
end
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
# Issue the request on a started Net::HTTP connection and read the
|
|
1304
|
+
# response body. Returns a [response, tempfile] pair; response is nil when
|
|
1305
|
+
# a :block_response was provided, since the block then handles the raw
|
|
1306
|
+
# Net::HTTPResponse itself.
|
|
1307
|
+
def perform_net_http_request(http, req, payload)
|
|
1308
|
+
tempfile = nil
|
|
1309
|
+
|
|
1310
|
+
if @block_response
|
|
1311
|
+
net_http_do_request(http, req, payload, &@block_response)
|
|
1312
|
+
[nil, nil]
|
|
1313
|
+
else
|
|
1314
|
+
res = net_http_do_request(http, req, payload) do |http_response|
|
|
1315
|
+
if @raw_response
|
|
1316
|
+
# fetch body into tempfile
|
|
1317
|
+
tempfile = fetch_body_to_tempfile(http_response)
|
|
1318
|
+
else
|
|
1319
|
+
# fetch body
|
|
1320
|
+
http_response.read_body
|
|
1321
|
+
end
|
|
1322
|
+
http_response
|
|
1323
|
+
end
|
|
1324
|
+
[res, tempfile]
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
# Build the connection pool key for a persistent request. Connections are
|
|
1329
|
+
# pooled per scheme/host/port plus proxy and the SSL settings that cannot
|
|
1330
|
+
# be changed on a live connection. A custom :ssl_cert_store is assumed to
|
|
1331
|
+
# be equivalent for a given host (a fresh default cert store object is
|
|
1332
|
+
# built for every request, so object identity cannot be part of the key).
|
|
1333
|
+
def persistent_connection_key(uri)
|
|
1334
|
+
proxy_part = proxy_uri ? proxy_uri.to_s : 'direct'
|
|
1335
|
+
cert_fingerprint = (OpenSSL::Digest::SHA256.hexdigest(ssl_client_cert.to_der) if ssl_client_cert)
|
|
1336
|
+
|
|
1337
|
+
[
|
|
1338
|
+
uri.scheme, uri.host, uri.port, proxy_part, verify_ssl,
|
|
1339
|
+
ssl_version, ssl_min_version, ssl_max_version, ssl_ciphers,
|
|
1340
|
+
ssl_ca_file, ssl_ca_path, cert_fingerprint,
|
|
1341
|
+
ssl_verify_callback&.object_id
|
|
1342
|
+
].join('|')
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
def setup_credentials(req)
|
|
1346
|
+
return unless user && !@processed_headers_lowercase.include?('authorization')
|
|
1347
|
+
|
|
1348
|
+
req.basic_auth(user, password)
|
|
1349
|
+
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
def fetch_body_to_tempfile(http_response)
|
|
1353
|
+
# Taken from Chef, which as in turn...
|
|
1354
|
+
# Stolen from http://www.ruby-forum.com/topic/166423
|
|
1355
|
+
# Kudos to _why!
|
|
1356
|
+
tf = Tempfile.new('contractful.')
|
|
1357
|
+
tf.binmode
|
|
1358
|
+
|
|
1359
|
+
size = 0
|
|
1360
|
+
total = http_response['Content-Length'].to_i
|
|
1361
|
+
stream_log_bucket = nil
|
|
1362
|
+
|
|
1363
|
+
# Wrap the read_body loop in begin/ensure so a write failure (disk
|
|
1364
|
+
# full, broken connection) cannot leak the tempfile's file
|
|
1365
|
+
# descriptor. Tempfile#close (without `true`) keeps the file on
|
|
1366
|
+
# disk for RawResponse to reopen; close(true) would unlink it,
|
|
1367
|
+
# which would break downstream code that reads the body later.
|
|
1368
|
+
begin
|
|
1369
|
+
http_response.read_body do |chunk|
|
|
1370
|
+
tf.write chunk
|
|
1371
|
+
size += chunk.size
|
|
1372
|
+
if log
|
|
1373
|
+
# Use the redacted URL here as everywhere else in the log: a URL
|
|
1374
|
+
# with embedded basic-auth credentials must not leak into the
|
|
1375
|
+
# streaming progress lines.
|
|
1376
|
+
if total == 0
|
|
1377
|
+
log << format("streaming %s %s (%d of unknown) [0 Content-Length]\n", @method.upcase, redacted_url, size)
|
|
1378
|
+
else
|
|
1379
|
+
percent = (size * 100) / total
|
|
1380
|
+
current_log_bucket, = percent.divmod(@stream_log_percent)
|
|
1381
|
+
if current_log_bucket != stream_log_bucket
|
|
1382
|
+
stream_log_bucket = current_log_bucket
|
|
1383
|
+
log << format("streaming %s %s %d%% done (%d of %d)\n", @method.upcase, redacted_url, (size * 100) / total, size, total)
|
|
1384
|
+
end
|
|
1385
|
+
end
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
ensure
|
|
1389
|
+
# Defensive close: the tempfile may be a partial double in tests
|
|
1390
|
+
# or a custom IO that does not implement closed?/close, but the
|
|
1391
|
+
# common case (real Tempfile) does. Avoid leaking the FD on a
|
|
1392
|
+
# write/read failure even when the IO shape is unusual.
|
|
1393
|
+
if tf.respond_to?(:closed?) && !tf.closed?
|
|
1394
|
+
tf.close if tf.respond_to?(:close)
|
|
1395
|
+
elsif tf.respond_to?(:close)
|
|
1396
|
+
tf.close
|
|
1397
|
+
end
|
|
1398
|
+
end
|
|
1399
|
+
tf
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
# @param res The Net::HTTP response object
|
|
1403
|
+
# @param start_time [Time] Time of request start
|
|
1404
|
+
def process_result(res, start_time, tempfile = nil, &block)
|
|
1405
|
+
if @raw_response
|
|
1406
|
+
raise ArgumentError.new('tempfile is required') unless tempfile
|
|
1407
|
+
|
|
1408
|
+
response = RawResponse.new(tempfile, res, self, start_time)
|
|
1409
|
+
else
|
|
1410
|
+
response = Response.create(res.body, res, self, start_time)
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
response.log_response
|
|
1414
|
+
|
|
1415
|
+
# Public API (frozen since rest-client 1.x, see docs/adr/0004): the
|
|
1416
|
+
# user block receives the Response (a String subclass), this request,
|
|
1417
|
+
# and the raw Net::HTTP result; the block itself is passed through as
|
|
1418
|
+
# a fourth argument for callers that delegate. Do not change the
|
|
1419
|
+
# arity or the Response inheritance without a major-version plan.
|
|
1420
|
+
if block_given?
|
|
1421
|
+
block.call(response, self, res, & block)
|
|
1422
|
+
else
|
|
1423
|
+
response.return!(&block)
|
|
1424
|
+
end
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
def parser
|
|
1428
|
+
URI.const_defined?(:Parser) ? URI::Parser.new : URI
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# Normalize an SSL version value into the form accepted by
|
|
1432
|
+
# OpenSSL::SSL::SSLContext#min_version=/max_version=. Symbols and strings
|
|
1433
|
+
# such as :TLSv1_2 or 'TLS1.3' are converted to the corresponding integer
|
|
1434
|
+
# protocol version constant when it is available; anything else (integers,
|
|
1435
|
+
# :SSLv23, unknown values) is passed through untouched so that OpenSSL can
|
|
1436
|
+
# apply its own validation.
|
|
1437
|
+
def normalize_ssl_version(value)
|
|
1438
|
+
return value unless value.is_a?(Symbol) || value.is_a?(String)
|
|
1439
|
+
|
|
1440
|
+
const_name = SSL_VERSION_CONSTANTS[value.to_s.upcase.tr('.', '_')]
|
|
1441
|
+
if const_name && OpenSSL::SSL.const_defined?(const_name)
|
|
1442
|
+
OpenSSL::SSL.const_get(const_name)
|
|
1443
|
+
else
|
|
1444
|
+
value
|
|
1445
|
+
end
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
# Given a MIME type or file extension, return either a MIME type or, if
|
|
1449
|
+
# none is found, the input unchanged.
|
|
1450
|
+
#
|
|
1451
|
+
# >> maybe_convert_extension('json')
|
|
1452
|
+
# => 'application/json'
|
|
1453
|
+
#
|
|
1454
|
+
# >> maybe_convert_extension('unknown')
|
|
1455
|
+
# => 'unknown'
|
|
1456
|
+
#
|
|
1457
|
+
# >> maybe_convert_extension('application/xml')
|
|
1458
|
+
# => 'application/xml'
|
|
1459
|
+
#
|
|
1460
|
+
# @param ext [String]
|
|
1461
|
+
#
|
|
1462
|
+
# @return [String]
|
|
1463
|
+
#
|
|
1464
|
+
def maybe_convert_extension(ext)
|
|
1465
|
+
unless ext =~ /\A[a-zA-Z0-9_@-]+\z/
|
|
1466
|
+
# Don't look up strings unless they look like they could be a file
|
|
1467
|
+
# extension known to mime-types.
|
|
1468
|
+
#
|
|
1469
|
+
# There currently isn't any API public way to look up extensions
|
|
1470
|
+
# directly out of MIME::Types, but the type_for() method only strips
|
|
1471
|
+
# off after a period anyway.
|
|
1472
|
+
return ext
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
types = MIME::Types.type_for(ext)
|
|
1476
|
+
if types.empty?
|
|
1477
|
+
ext
|
|
1478
|
+
else
|
|
1479
|
+
types.first.content_type
|
|
1480
|
+
end
|
|
1481
|
+
end
|
|
1482
|
+
end
|
|
1483
|
+
end
|