x-core 1.0.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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +353 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +54 -0
  6. data/lib/x/core/app_only_authenticator.rb +198 -0
  7. data/lib/x/core/authenticator.rb +74 -0
  8. data/lib/x/core/authenticator_request.rb +56 -0
  9. data/lib/x/core/bearer_token_authenticator.rb +46 -0
  10. data/lib/x/core/built_response.rb +100 -0
  11. data/lib/x/core/client.rb +728 -0
  12. data/lib/x/core/client_app_only.rb +115 -0
  13. data/lib/x/core/client_credentials.rb +193 -0
  14. data/lib/x/core/client_internals.rb +315 -0
  15. data/lib/x/core/client_memo.rb +58 -0
  16. data/lib/x/core/client_settings.rb +166 -0
  17. data/lib/x/core/client_token_refresh.rb +281 -0
  18. data/lib/x/core/connection.rb +328 -0
  19. data/lib/x/core/connection_pool.rb +123 -0
  20. data/lib/x/core/connection_proxy.rb +151 -0
  21. data/lib/x/core/connection_request.rb +90 -0
  22. data/lib/x/core/credential_holder.rb +78 -0
  23. data/lib/x/core/credential_validator.rb +286 -0
  24. data/lib/x/core/errors/authorization_denied.rb +55 -0
  25. data/lib/x/core/errors/authorization_error.rb +41 -0
  26. data/lib/x/core/errors/bad_gateway.rb +9 -0
  27. data/lib/x/core/errors/bad_request.rb +10 -0
  28. data/lib/x/core/errors/callback_error.rb +88 -0
  29. data/lib/x/core/errors/client_error.rb +17 -0
  30. data/lib/x/core/errors/conflict.rb +9 -0
  31. data/lib/x/core/errors/error.rb +66 -0
  32. data/lib/x/core/errors/forbidden.rb +10 -0
  33. data/lib/x/core/errors/gateway_timeout.rb +9 -0
  34. data/lib/x/core/errors/gone.rb +9 -0
  35. data/lib/x/core/errors/http_error.rb +333 -0
  36. data/lib/x/core/errors/internal_server_error.rb +9 -0
  37. data/lib/x/core/errors/invalid_response.rb +96 -0
  38. data/lib/x/core/errors/method_not_allowed.rb +10 -0
  39. data/lib/x/core/errors/network_error.rb +59 -0
  40. data/lib/x/core/errors/not_acceptable.rb +10 -0
  41. data/lib/x/core/errors/not_found.rb +15 -0
  42. data/lib/x/core/errors/payload_too_large.rb +10 -0
  43. data/lib/x/core/errors/payment_required.rb +10 -0
  44. data/lib/x/core/errors/request_timeout.rb +9 -0
  45. data/lib/x/core/errors/server_error.rb +15 -0
  46. data/lib/x/core/errors/service_unavailable.rb +9 -0
  47. data/lib/x/core/errors/token_report_failed.rb +59 -0
  48. data/lib/x/core/errors/too_many_redirects.rb +53 -0
  49. data/lib/x/core/errors/too_many_requests.rb +80 -0
  50. data/lib/x/core/errors/unauthorized.rb +15 -0
  51. data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
  52. data/lib/x/core/errors/unprocessable_entity.rb +10 -0
  53. data/lib/x/core/errors/unsupported_format.rb +22 -0
  54. data/lib/x/core/errors/unsupported_media_type.rb +10 -0
  55. data/lib/x/core/errors/unsupported_operation.rb +18 -0
  56. data/lib/x/core/oauth1_authenticator.rb +142 -0
  57. data/lib/x/core/oauth2_authenticator.rb +296 -0
  58. data/lib/x/core/oauth2_authorization.rb +405 -0
  59. data/lib/x/core/oauth2_refresh.rb +272 -0
  60. data/lib/x/core/oauth2_tokens.rb +277 -0
  61. data/lib/x/core/origin.rb +95 -0
  62. data/lib/x/core/problem.rb +302 -0
  63. data/lib/x/core/proxy_setting.rb +22 -0
  64. data/lib/x/core/rate_limit.rb +137 -0
  65. data/lib/x/core/rate_limit_handler.rb +166 -0
  66. data/lib/x/core/redirect_handler.rb +208 -0
  67. data/lib/x/core/refresh_reporter.rb +92 -0
  68. data/lib/x/core/request_builder.rb +155 -0
  69. data/lib/x/core/request_context.rb +85 -0
  70. data/lib/x/core/request_encoding.rb +135 -0
  71. data/lib/x/core/response.rb +196 -0
  72. data/lib/x/core/response_headers.rb +31 -0
  73. data/lib/x/core/response_parser.rb +155 -0
  74. data/lib/x/core/retry_handler.rb +149 -0
  75. data/lib/x/core/setting_validator.rb +399 -0
  76. data/lib/x/core/stream_body.rb +77 -0
  77. data/lib/x/core/stream_response.rb +267 -0
  78. data/lib/x/core/token_endpoint.rb +238 -0
  79. data/lib/x/core/version.rb +25 -0
  80. data/lib/x/core.rb +6 -0
  81. data/sig/manifest.yaml +8 -0
  82. data/sig/x-core.rbs +396 -0
  83. metadata +162 -0
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "zlib"
7
+ require_relative "connection_pool"
8
+ require_relative "connection_proxy"
9
+ require_relative "connection_request"
10
+ require_relative "proxy_setting"
11
+ require_relative "request_context"
12
+ require_relative "setting_validator"
13
+ require_relative "errors/network_error"
14
+ require_relative "errors/callback_error"
15
+
16
+ module X
17
+ module Core
18
+ # Manages HTTP connections to the X API
19
+ #
20
+ # Internal to x-core: Client, and the authenticators and authorization that fetch tokens send
21
+ # their requests with it, so that it can change within 1.x. Configure it through the settings of Client, such as
22
+ # proxy_url and the timeouts.
23
+ #
24
+ # Requests keep their connections open for the next request to the same host, which saves opening a TCP and
25
+ # TLS connection each time. A stream opens a connection of its own, which it holds for as long as it reads.
26
+ #
27
+ # A connection keeps the settings it was built with for as long as it lives, as {Client} does, so a request never
28
+ # opens a connection under a setting another thread is halfway through changing. Build another connection to
29
+ # reach the API differently.
30
+ #
31
+ # @api private
32
+ class Connection
33
+ include ConnectionProxy
34
+ include ConnectionRequest
35
+ include ProxySetting
36
+
37
+ # Default timeout for opening connections in seconds; opening a connection is a TCP handshake and a TLS one,
38
+ # which a reachable host finishes in well under a second, so a host that takes longer is one a request waits
39
+ # on rather than reaches, and it is given less time than reading a response, which an endpoint may be slow to
40
+ # send
41
+ DEFAULT_OPEN_TIMEOUT = 10 # seconds
42
+ # Default timeout for reading responses in seconds
43
+ DEFAULT_READ_TIMEOUT = 60 # seconds
44
+ # Default timeout for writing requests in seconds
45
+ DEFAULT_WRITE_TIMEOUT = 60 # seconds
46
+ # Default time to keep a connection open for the next request to the same host, in seconds; X holds an idle
47
+ # connection open for more than five minutes, so a connection closed within this time was closed by a proxy
48
+ DEFAULT_KEEP_ALIVE_TIMEOUT = 30 # seconds
49
+ # The settings a connection is opened under that are values, which a connection that shares the pool of another
50
+ # holds equal ones of; the debug output is an object that is written to, so it is the same one, rather than an
51
+ # equal one, as an empty Array is to another
52
+ SETTINGS = %i[open_timeout read_timeout write_timeout keep_alive_timeout proxy_url].freeze
53
+ private_constant :SETTINGS
54
+ # The timeout for opening connections in seconds
55
+ # @api private
56
+ # @return [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none
57
+ # @example Get the open timeout
58
+ # connection.open_timeout # => 10
59
+ attr_reader :open_timeout
60
+
61
+ # The timeout for reading responses in seconds
62
+ # @api private
63
+ # @return [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none
64
+ # @example Get the read timeout
65
+ # connection.read_timeout # => 60
66
+ attr_reader :read_timeout
67
+
68
+ # The timeout for writing requests in seconds
69
+ # @api private
70
+ # @return [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none
71
+ # @example Get the write timeout
72
+ # connection.write_timeout # => 60
73
+ attr_reader :write_timeout
74
+
75
+ # The IO object for debug output
76
+ # @api private
77
+ # @return [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<, such as
78
+ # a Logger, or nil for none
79
+ # @example Get the debug output
80
+ # connection.debug_output
81
+ attr_reader :debug_output
82
+
83
+ # The time to keep a connection open for the next request, in seconds
84
+ # @api private
85
+ # @return [Integer, Float] the time in seconds
86
+ # @example Get the keep-alive timeout
87
+ # connection.keep_alive_timeout # => 30
88
+ attr_reader :keep_alive_timeout
89
+
90
+ # Initialize a new connection
91
+ #
92
+ # @api private
93
+ # @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none
94
+ # @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none
95
+ # @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none
96
+ # @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same
97
+ # host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered
98
+ # @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<,
99
+ # such as a StringIO or a Logger
100
+ # @param proxy_url [String, URI::Generic, nil] the proxy URL for requests
101
+ # @return [Connection] a new connection instance
102
+ # @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor, for any but
103
+ # keep_alive_timeout, nil
104
+ # @raise [ArgumentError] if debug_output is a String, or is neither nil nor takes a String with <<
105
+ # @example Create a connection with default settings
106
+ # connection = X::Core::Connection.new
107
+ # @example Create a connection with custom timeouts
108
+ # connection = X::Core::Connection.new(open_timeout: 30, read_timeout: 30)
109
+ def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
110
+ write_timeout: DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, proxy_url: nil)
111
+ @open_timeout = SettingValidator.timeout!(:open_timeout, open_timeout)
112
+ @read_timeout = SettingValidator.timeout!(:read_timeout, read_timeout)
113
+ @write_timeout = SettingValidator.timeout!(:write_timeout, write_timeout)
114
+ @keep_alive_timeout = SettingValidator.finite_seconds!(:keep_alive_timeout, keep_alive_timeout)
115
+ @debug_output = SettingValidator.debug_output!(debug_output)
116
+ @pool = ConnectionPool.new
117
+ initialize_proxy(proxy_url)
118
+ end
119
+
120
+ # Summarize the connection for the console without revealing proxy credentials
121
+ #
122
+ # @api private
123
+ # @return [String] the class name, proxy URL, and timeouts
124
+ # @example Inspect a connection
125
+ # connection.inspect # => #<X::Core::Connection proxy_url="http://proxy.example.com:8080" open_timeout=10 ...>
126
+ def inspect
127
+ "#<#{self.class} proxy_url=#{redacted_proxy_url.inspect} open_timeout=#{open_timeout} " \
128
+ "read_timeout=#{read_timeout} write_timeout=#{write_timeout}>"
129
+ end
130
+
131
+ # Perform an HTTP request
132
+ #
133
+ # Internal to x-core: Client, its redirects, and token requests send their requests with it, so that it can change
134
+ # within 1.x, as the Net::HTTP requests it takes may.
135
+ #
136
+ # The body of the response is tagged UTF-8, the encoding of the JSON the API sends, rather than the binary that
137
+ # Net::HTTP reads it as, so that it can be searched and joined with other Strings. A body that is not valid
138
+ # UTF-8, such as the page of a proxy in another encoding, keeps its bytes, and valid_encoding? tells it apart.
139
+ #
140
+ # @api private
141
+ # @param request [Net::HTTPRequest] the HTTP request to perform
142
+ # @return [Net::HTTPResponse] the HTTP response, whose body is tagged UTF-8
143
+ # @raise [NetworkError] if a network error occurs, or Net::HTTP cannot read the response, as one whose header
144
+ # holds a bare CR, for which it raises ArgumentError
145
+ # @raise [ArgumentError] if the connection was given no proxy and the proxy the environment names for the request
146
+ # cannot be parsed, or is not an HTTP or HTTPS URL that names a host, with a message that names the variables
147
+ # read for its scheme and leaves out the value, and with no cause
148
+ # @example Perform a request
149
+ # response = connection.perform(request: request)
150
+ def perform(request:)
151
+ uri = request.uri
152
+ hostname, port = host_and_port(uri)
153
+ use_ssl = uri.scheme.eql?("https")
154
+ send_request(request, [use_ssl, hostname, port], -> { open_http_client(uri, use_ssl) })
155
+ rescue InvalidEnvironmentProxy
156
+ raise environment_proxy_error(request.uri), cause: nil
157
+ rescue *NETWORK_ERRORS, ArgumentError => e
158
+ raise NetworkError.new("Network error: #{e}", **RequestContext.of(request))
159
+ end
160
+
161
+ # Perform a streaming HTTP request
162
+ #
163
+ # Internal to x-core: Client#get_stream opens its requests with it.
164
+ #
165
+ # The connection is opened for this request and closed once the block returns, rather than taken from the
166
+ # connections kept open and given back, since a stream holds its connection for as long as it reads. Once the
167
+ # block returns, the connection is closed with what is left of the body unread, rather than read to its end, as
168
+ # Net::HTTP would otherwise read it, which a stream never reaches.
169
+ #
170
+ # An error the block raises, which the client tags as a CallbackError, is raised tagged, rather than reported as
171
+ # a network error: the block of a stream runs inside the request that reads it. The NetworkError the read_body
172
+ # of a StreamResponse raises for an error of the socket, which the client leaves untagged, is raised as it is,
173
+ # rather than wrapped in another.
174
+ #
175
+ # The body is not tagged UTF-8 here, as the body of {#perform} is: Net::HTTP tags a body it reads whole, and
176
+ # raises for one it passes to a block a chunk at a time, as a stream is read.
177
+ #
178
+ # @api private
179
+ # @param request [Net::HTTPRequest] the HTTP request to perform
180
+ # @yield [Net::HTTPResponse] the HTTP response for streaming
181
+ # @return [Object] what the block returns
182
+ # @raise [NetworkError] if a network error occurs, or Net::HTTP cannot read the response, as one whose header
183
+ # holds a bare CR, for which it raises ArgumentError
184
+ # @raise [ArgumentError] if the connection was given no proxy and the proxy the environment names for the request
185
+ # cannot be parsed, or is not an HTTP or HTTPS URL that names a host, with a message that names the variables
186
+ # read for its scheme and leaves out the value, and with no cause
187
+ # @raise [CallbackError] if the block raises an error the client tagged
188
+ # @example Perform a streaming request
189
+ # connection.perform_stream(request: request) { |response| response.read_body { |chunk| } }
190
+ def perform_stream(request:)
191
+ http_client = build_http_client(request.uri)
192
+ http_client.use_ssl = request.uri.scheme.eql?("https")
193
+ catch { |done| http_client.request(request) { |response| throw done, yield(response) } }
194
+ rescue InvalidEnvironmentProxy
195
+ raise environment_proxy_error(request.uri), cause: nil
196
+ rescue *NETWORK_ERRORS, ArgumentError => e
197
+ raise NetworkError.new("Network error: #{e}", **RequestContext.of(request))
198
+ end
199
+
200
+ # Check whether an error is one a request raises as a NetworkError
201
+ #
202
+ # @api private
203
+ # @param error [Exception] the error
204
+ # @return [Boolean] true if the error is one of a socket
205
+ # @example Check an error a stream raised
206
+ # X::Core::Connection.network_error?(IOError.new) # => true
207
+ def self.network_error?(error) = NETWORK_ERRORS.any? { |network_error| error.is_a?(network_error) }
208
+
209
+ # Close the connections kept open between requests
210
+ #
211
+ # A later request opens a connection again. Nothing else closes them: the sockets of a connection that is
212
+ # dropped rather than closed are shut as the garbage collector reclaims them, at a time the process does not
213
+ # choose and without the shutdown this performs.
214
+ #
215
+ # @api private
216
+ # @return [void]
217
+ # @example Close the connections before a long pause
218
+ # connection.close
219
+ def close
220
+ @pool.clear
221
+ end
222
+
223
+ # Take connections from the pool of another connection that opens them alike
224
+ #
225
+ # Internal to x-core: Client#with gives a copy the connections of the client it was copied from with it. The
226
+ # two then take their connections from one pool, and close them together, so a copy made for each request opens
227
+ # none of its own. A connection whose timeouts, keep-alive timeout, debug output, or proxy differ keeps a pool
228
+ # of its own, since a connection is opened under those. The debug output differs unless it is the same object,
229
+ # since a connection writes to the one it was opened under: an Array is equal to another that holds the same,
230
+ # and a request written to the wrong one is lost to the one it was meant for.
231
+ #
232
+ # @api private
233
+ # @param other [Connection] the connection whose pool to share
234
+ # @return [void]
235
+ # @example Share the connections of another connection
236
+ # connection.share_pool_of(other)
237
+ def share_pool_of(other)
238
+ output = debug_output #: untyped
239
+ @pool = other.__send__(:pool) if output.equal?(other.debug_output) && SETTINGS.all? { |name| __send__(name) == other.__send__(name) }
240
+ end
241
+
242
+ private
243
+
244
+ # The connections kept open between requests
245
+ # @api private
246
+ # @return [ConnectionPool] the pool
247
+ attr_reader :pool
248
+
249
+ # The host and port to connect to for a URI
250
+ #
251
+ # The URI of a request is an HTTP or HTTPS URL, since Net::HTTP builds a request from no other, so it names both.
252
+ #
253
+ # @api private
254
+ # @param uri [URI::Generic] the URI of the request
255
+ # @return [Array(String, Integer)] the host and the port to connect to
256
+ def host_and_port(uri)
257
+ hostname = uri.hostname #: String
258
+ port = uri.port #: Integer
259
+ [hostname, port]
260
+ end
261
+
262
+ # Open an HTTP client for requests read whole, which tags each body UTF-8
263
+ #
264
+ # @api private
265
+ # @param uri [URI::Generic] the URI of the request
266
+ # @param use_ssl [Boolean] whether to connect over TLS
267
+ # @return [Net::HTTP] the HTTP client
268
+ def open_http_client(uri, use_ssl)
269
+ build_http_client(uri).tap do |http_client|
270
+ http_client.use_ssl = use_ssl
271
+ http_client.response_body_encoding = Encoding::UTF_8
272
+ end
273
+ end
274
+
275
+ # Build an HTTP client for the host of a URI
276
+ #
277
+ # The client connects to an HTTPS proxy over TLS. A client that reaches its host directly is given no proxy to
278
+ # resolve, rather than the :ENV of Net::HTTP, which reads http_proxy for a request of any scheme: the proxy of a
279
+ # request is resolved from the scheme of its own URI, by ConnectionProxy.
280
+ #
281
+ # @api private
282
+ # @param uri [URI::Generic] the URI of the request
283
+ # @return [Net::HTTP] the HTTP client
284
+ def build_http_client(uri)
285
+ host, port = host_and_port(uri)
286
+ proxy = proxy_for(uri)
287
+ http_client = if proxy
288
+ Net::HTTP.new(host, port, proxy.hostname, proxy.port, decode(proxy.user), decode(proxy.password), nil, proxy.instance_of?(URI::HTTPS))
289
+ else
290
+ Net::HTTP.new(host, port, nil)
291
+ end
292
+ configure_http_client(http_client)
293
+ end
294
+
295
+ # Configure a new HTTP client with the timeouts and debug output of the connection
296
+ #
297
+ # The settings of a connection never change, so they are applied as each client is opened, rather than before
298
+ # each request one makes.
299
+ #
300
+ # Net::HTTP sends a GET, PUT, or DELETE request again by itself after a timeout or a dropped connection, with the
301
+ # same OAuth 1.0a nonce and signature, which the API may bill twice, so its retries are turned off: a request
302
+ # that fails raises NetworkError, and the caller decides whether to send it again.
303
+ #
304
+ # Net::HTTP keeps a connection for two seconds by default, which reuses it within a burst of requests alone, so
305
+ # it keeps one for keep_alive_timeout instead.
306
+ #
307
+ # Net::HTTP returns a body that ends before the length its Content-Length gives as though it were whole, which
308
+ # would be parsed as JSON cut short, so ignore_eof is turned off: a connection that closes before the body is
309
+ # read raises EOFError, which a request raises as a NetworkError.
310
+ #
311
+ # @api private
312
+ # @param http_client [Net::HTTP] the HTTP client to configure
313
+ # @return [Net::HTTP] the configured HTTP client
314
+ def configure_http_client(http_client)
315
+ http_client.tap do |c|
316
+ c.open_timeout = open_timeout
317
+ c.read_timeout = read_timeout
318
+ c.write_timeout = write_timeout
319
+ c.max_retries = 0
320
+ c.ignore_eof = false
321
+ c.keep_alive_timeout = keep_alive_timeout
322
+ c.set_debug_output(debug_output)
323
+ end
324
+ end
325
+ end
326
+ private_constant :Connection
327
+ end
328
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module X
6
+ module Core
7
+ # The open HTTP connections of a Connection, kept to be used again by the next request to the same host
8
+ #
9
+ # A connection is used by one request at a time: a request takes an idle connection, or opens one, and gives
10
+ # it back once it has read the response. A request that fails closes its connection instead, and so does one
11
+ # that finishes after clear. A forked process opens
12
+ # connections of its own rather than share its parent's.
13
+ #
14
+ # @api private
15
+ class ConnectionPool
16
+ # Most idle connections kept open to each host, as many as the chunks x-uploads sends at once at most, so that
17
+ # each sender of an upload keeps its connection from one chunk to the next
18
+ MAX_IDLE = 16
19
+
20
+ # Initialize an empty pool
21
+ #
22
+ # @api private
23
+ # @return [ConnectionPool] a new pool
24
+ def initialize
25
+ @lock = Mutex.new
26
+ end
27
+
28
+ # Run a block with an open connection, keeping it for later if the block returns
29
+ #
30
+ # The block is told whether the connection was one the pool had kept open, since a connection the peer closed
31
+ # while it was idle fails the request that takes it, where one opened for the request did not go stale.
32
+ #
33
+ # @api private
34
+ # @param key [Array] the scheme, host, and port the connection is to
35
+ # @param open [Proc] builds a connection to the host, when none is idle
36
+ # @param fresh [Boolean] whether to open a connection even when one is idle
37
+ # @yield [Net::HTTP, Boolean] the started connection, and whether it came from the pool
38
+ # @return [Object] what the block returns
39
+ def with(key, open, fresh: false)
40
+ pool, http_client, pooled = checkout(key, open, fresh)
41
+ kept = nil
42
+ begin
43
+ result = yield http_client, pooled
44
+ kept = checkin(key, http_client, pool)
45
+ result
46
+ ensure
47
+ close(http_client) unless kept
48
+ end
49
+ end
50
+
51
+ # Close every idle connection, and each one in use once its request finishes
52
+ #
53
+ # A forked process leaves the connections its parent opened alone, since closing one would end the parent's
54
+ # TLS session over the socket they share.
55
+ #
56
+ # @api private
57
+ # @return [void]
58
+ def clear
59
+ idle = @lock.synchronize do
60
+ forget_after_fork
61
+ @idle.values.flatten.tap { @idle = {} }
62
+ end
63
+ idle.each { |http_client| close(http_client) }
64
+ end
65
+
66
+ private
67
+
68
+ # Take an idle connection to a host, or open one
69
+ # @api private
70
+ # @param key [Array] the scheme, host, and port
71
+ # @param open [Proc] builds a connection to the host
72
+ # @param fresh [Boolean] whether to open a connection even when one is idle
73
+ # @return [Array(Hash, Net::HTTP, bool)] the idle connections the connection returns to, the started
74
+ # connection, and whether it was idle rather than opened for this request
75
+ def checkout(key, open, fresh)
76
+ pool, idle = @lock.synchronize do
77
+ forget_after_fork
78
+ [@idle, (@idle[key]&.pop unless fresh)]
79
+ end
80
+ http_client = idle || open.call
81
+ http_client.start unless http_client.started?
82
+ [pool, http_client, !idle.nil?]
83
+ end
84
+
85
+ # Keep a connection for later, unless the pool was cleared or is full
86
+ # @api private
87
+ # @param key [Array] the scheme, host, and port
88
+ # @param http_client [Net::HTTP] the connection
89
+ # @param pool [Hash] the idle connections when the connection was taken, which clear replaces
90
+ # @return [Array<Net::HTTP>, nil] the idle connections to the host, or nil if the connection was not kept
91
+ def checkin(key, http_client, pool)
92
+ @lock.synchronize do
93
+ idle = @idle[key] ||= []
94
+ idle.push(http_client) if pool.equal?(@idle) && idle.size < MAX_IDLE
95
+ end
96
+ end
97
+
98
+ # Drop the connections a parent process opened, which a fork must not share
99
+ #
100
+ # The first call in a process, which is the first in a new pool, starts the pool with no connections.
101
+ #
102
+ # @api private
103
+ # @return [void]
104
+ def forget_after_fork
105
+ return if @pid.eql?(Process.pid)
106
+
107
+ @idle = {}
108
+ @pid = Process.pid
109
+ end
110
+
111
+ # Close a connection, unless it is closed already
112
+ # @api private
113
+ # @param http_client [Net::HTTP] the connection
114
+ # @return [void]
115
+ def close(http_client)
116
+ http_client.finish
117
+ rescue IOError
118
+ nil
119
+ end
120
+ end
121
+ private_constant :ConnectionPool
122
+ end
123
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module X
6
+ module Core
7
+ # The proxy of a connection, parsed from its URL, included into Connection
8
+ #
9
+ # A proxy URL can hold the user and password of the proxy, so a connection reveals neither its URL nor anything
10
+ # parsed from it, and its inspect leaves out the user and password; see {ProxySetting}.
11
+ #
12
+ # A connection given no proxy URL takes the proxy the environment names for each request, and sends a request to
13
+ # a host that no_proxy names without a proxy. Set no_proxy to reach every host directly from a process whose
14
+ # environment names a proxy. A proxy of the environment that cannot be parsed, or that is not an HTTP or HTTPS
15
+ # URL that names a host, such as proxy.example.com:8080, which has no scheme, is refused as a proxy URL given is,
16
+ # with an ArgumentError that names the variables it is read from and leaves out its value, which can hold a
17
+ # password, though by the request that reads it, since the environment is read for each request that opens a
18
+ # connection, rather than where the client is built. A variable that is empty names no proxy, as it names none
19
+ # to Ruby.
20
+ #
21
+ # Most HTTP clients take the proxy the environment names, and Net::HTTP reads http_proxy alone, whatever the scheme of the request, which would leave the HTTPS requests
22
+ # these gems make unproxied for anyone who sets https_proxy, and proxied through whatever http_proxy names for
23
+ # anyone who sets that instead. So the proxy of a request is resolved here, from the scheme of its own URI, and
24
+ # passed to Net::HTTP, which is given no proxy of its own to resolve.
25
+ #
26
+ # @api private
27
+ module ConnectionProxy
28
+ # The message of the error an HTTPS request raises for a proxy of the environment that is no proxy URL, which
29
+ # names the variables URI::Generic#find_proxy reads for one, in the order it reads them, and leaves out the value
30
+ INVALID_HTTPS_PROXY = "Invalid proxy URL in the environment, in https_proxy or HTTPS_PROXY"
31
+ # The message of the error an HTTP request raises for a proxy of the environment that is no proxy URL, which
32
+ # names the variables URI::Generic#find_proxy reads for one, and leaves out the value: http_proxy, and else
33
+ # HTTP_PROXY, or, in a CGI process, which REQUEST_METHOD is set in, CGI_HTTP_PROXY in place of HTTP_PROXY
34
+ INVALID_HTTP_PROXY = "Invalid proxy URL in the environment, in http_proxy, HTTP_PROXY, or CGI_HTTP_PROXY"
35
+ private_constant :INVALID_HTTPS_PROXY, :INVALID_HTTP_PROXY
36
+
37
+ # Raised for a proxy of the environment that cannot be parsed, or is not an HTTP or HTTPS URL that names a host,
38
+ # inside a request, which raises an ArgumentError for it in turn, rather than the NetworkError it raises for an
39
+ # ArgumentError of Net::HTTP
40
+ #
41
+ # @api private
42
+ class InvalidEnvironmentProxy < StandardError; end
43
+ private_constant :InvalidEnvironmentProxy
44
+
45
+ private
46
+
47
+ # The parsed proxy URI
48
+ # @api private
49
+ # @return [URI::Generic, nil] the parsed proxy URI, or nil to take the proxy the environment names
50
+ attr_reader :proxy_uri
51
+
52
+ # Read the proxy URL a connection is built with
53
+ #
54
+ # A connection keeps it for as long as it lives, as it keeps every setting. The message of an invalid URL
55
+ # leaves out its user and password.
56
+ #
57
+ # @api private
58
+ # @param proxy_url [String, URI::Generic, nil] the proxy URL, or nil to take the proxy from the environment
59
+ # @return [void]
60
+ # @raise [ArgumentError] if the proxy URL is invalid
61
+ def initialize_proxy(proxy_url)
62
+ @proxy_uri = proxy_url&.then { |url| parse_proxy_url(url) }
63
+ @proxy_url = @proxy_uri&.then { |uri| String(uri) }
64
+ end
65
+
66
+ # The proxy of a request, the one given or the one the environment names
67
+ #
68
+ # The proxy the connection was given stands for every request. Otherwise the URI of the request is asked for
69
+ # the proxy of its own scheme, which is held to what a proxy URL given is: URI::Generic#find_proxy parses a
70
+ # value without a scheme, such as proxy.example.com:8080, into a URI that names no host, which Net::HTTP would
71
+ # take for no proxy at all, and send the request around the proxy its environment names. A variable that is
72
+ # empty, and a host that no_proxy names, have no proxy to check. The error of a proxy of the environment that
73
+ # cannot be parsed has no cause, since the message of the URI::InvalidURIError holds the value of the variable
74
+ # whole, with the password a proxy URL can hold.
75
+ #
76
+ # @api private
77
+ # @param uri [URI::Generic] the URI of the request
78
+ # @return [URI::Generic, nil] the proxy of the request, or nil to reach the host directly
79
+ # @raise [InvalidEnvironmentProxy] if the proxy the environment names cannot be parsed, or is not an HTTP or
80
+ # HTTPS URL that names a host
81
+ def proxy_for(uri)
82
+ proxy_uri || uri.find_proxy&.tap { |proxy| raise InvalidEnvironmentProxy unless proxy_server?(proxy) }
83
+ rescue URI::InvalidURIError
84
+ raise InvalidEnvironmentProxy, cause: nil
85
+ end
86
+
87
+ # The error a request raises for a proxy of the environment that is no proxy URL
88
+ #
89
+ # The variable the proxy was read from is not looked up again, which would repeat the lookup of
90
+ # URI::Generic#find_proxy, so the message names each variable that is read for the scheme of the request, in
91
+ # either case, and is true whichever of them was set.
92
+ #
93
+ # @api private
94
+ # @param uri [URI::Generic] the URI of the request, whose scheme names the variables the proxy was read from
95
+ # @return [ArgumentError] the error, whose message names the variables and leaves out the value
96
+ def environment_proxy_error(uri) = ArgumentError.new(uri.scheme.eql?("https") ? INVALID_HTTPS_PROXY : INVALID_HTTP_PROXY)
97
+
98
+ # A percent-encoded component of a proxy URL, decoded
99
+ #
100
+ # @api private
101
+ # @param component [String, nil] the component, or nil for none
102
+ # @return [String, nil] the decoded component, or nil for none
103
+ def decode(component) = component&.then { |value| URI.decode_uri_component(value) }
104
+
105
+ # The proxy URL without its user and password
106
+ # @api private
107
+ # @return [String, nil] the proxy URL, without its user and password
108
+ def redacted_proxy_url = proxy_url&.then { |url| redact(url) }
109
+
110
+ # Parse a proxy URL, which must be an HTTP or HTTPS URL that names a host
111
+ #
112
+ # A URL that names no host, such as http:proxy:8080, is refused, since Net::HTTP would take its missing host for
113
+ # no proxy at all and send each request around the proxy. The URL is parsed anew, so that a URI the caller
114
+ # changes later does not change the proxy.
115
+ #
116
+ # @api private
117
+ # @param proxy_url [String, URI::Generic] the proxy URL
118
+ # @return [URI::HTTP] the parsed proxy URL
119
+ # @raise [ArgumentError] if the proxy URL is invalid
120
+ def parse_proxy_url(proxy_url)
121
+ proxy_uri = URI(String(proxy_url))
122
+ raise ArgumentError, "Invalid proxy URL: #{redact(proxy_url)}" unless proxy_server?(proxy_uri)
123
+
124
+ proxy_uri
125
+ rescue URI::InvalidURIError
126
+ raise ArgumentError, "Invalid proxy URL: #{redact(proxy_url)}", cause: nil
127
+ end
128
+
129
+ # Check whether a URI names a proxy, as an HTTP or HTTPS URL with a host does
130
+ #
131
+ # A proxy URL given and the proxy of the environment are held to it alike. A user, a password, a port, and a
132
+ # path are each allowed, and the path is not read.
133
+ #
134
+ # @api private
135
+ # @param uri [URI::Generic] the parsed proxy URL
136
+ # @return [Boolean] true if the URI is an HTTP or HTTPS URL that names a host
137
+ def proxy_server?(uri) = uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
138
+
139
+ # A proxy URL without its user and password
140
+ #
141
+ # A password written into a URL unescaped can hold any character, an @ or a / among them, so everything
142
+ # between the scheme and the last @ is left out, and everything before the last @ of a URL without a scheme.
143
+ #
144
+ # @api private
145
+ # @param proxy_url [String, URI::Generic] the proxy URL
146
+ # @return [String] the URL, without the user and password
147
+ def redact(proxy_url) = String(proxy_url).sub(%r{([^/]*//)?.*@}m, "\\1")
148
+ end
149
+ private_constant :ConnectionProxy
150
+ end
151
+ end