http 6.0.0-java
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/CHANGELOG.md +267 -0
- data/CONTRIBUTING.md +26 -0
- data/LICENSE.txt +20 -0
- data/README.md +263 -0
- data/SECURITY.md +17 -0
- data/UPGRADING.md +491 -0
- data/http.gemspec +48 -0
- data/lib/http/base64.rb +22 -0
- data/lib/http/chainable/helpers.rb +62 -0
- data/lib/http/chainable/verbs.rb +136 -0
- data/lib/http/chainable.rb +377 -0
- data/lib/http/client.rb +230 -0
- data/lib/http/connection/internals.rb +141 -0
- data/lib/http/connection.rb +265 -0
- data/lib/http/content_type.rb +89 -0
- data/lib/http/errors.rb +67 -0
- data/lib/http/feature.rb +86 -0
- data/lib/http/features/auto_deflate.rb +230 -0
- data/lib/http/features/auto_inflate.rb +64 -0
- data/lib/http/features/caching/entry.rb +178 -0
- data/lib/http/features/caching/in_memory_store.rb +63 -0
- data/lib/http/features/caching.rb +216 -0
- data/lib/http/features/digest_auth.rb +234 -0
- data/lib/http/features/instrumentation.rb +149 -0
- data/lib/http/features/logging.rb +231 -0
- data/lib/http/features/normalize_uri.rb +34 -0
- data/lib/http/features/raise_error.rb +37 -0
- data/lib/http/form_data/composite_io.rb +106 -0
- data/lib/http/form_data/file.rb +95 -0
- data/lib/http/form_data/multipart/param.rb +62 -0
- data/lib/http/form_data/multipart.rb +106 -0
- data/lib/http/form_data/part.rb +52 -0
- data/lib/http/form_data/readable.rb +58 -0
- data/lib/http/form_data/urlencoded.rb +175 -0
- data/lib/http/form_data/version.rb +8 -0
- data/lib/http/form_data.rb +102 -0
- data/lib/http/headers/known.rb +90 -0
- data/lib/http/headers/normalizer.rb +50 -0
- data/lib/http/headers.rb +343 -0
- data/lib/http/mime_type/adapter.rb +43 -0
- data/lib/http/mime_type/json.rb +41 -0
- data/lib/http/mime_type.rb +96 -0
- data/lib/http/options/definitions.rb +189 -0
- data/lib/http/options.rb +241 -0
- data/lib/http/redirector.rb +157 -0
- data/lib/http/request/body.rb +181 -0
- data/lib/http/request/builder.rb +184 -0
- data/lib/http/request/proxy.rb +83 -0
- data/lib/http/request/writer.rb +186 -0
- data/lib/http/request.rb +375 -0
- data/lib/http/response/body.rb +172 -0
- data/lib/http/response/inflater.rb +60 -0
- data/lib/http/response/parser.rb +223 -0
- data/lib/http/response/status/reasons.rb +79 -0
- data/lib/http/response/status.rb +263 -0
- data/lib/http/response.rb +350 -0
- data/lib/http/retriable/delay_calculator.rb +91 -0
- data/lib/http/retriable/errors.rb +35 -0
- data/lib/http/retriable/performer.rb +197 -0
- data/lib/http/session.rb +280 -0
- data/lib/http/timeout/global.rb +229 -0
- data/lib/http/timeout/null.rb +225 -0
- data/lib/http/timeout/per_operation.rb +197 -0
- data/lib/http/uri/normalizer.rb +82 -0
- data/lib/http/uri/parsing.rb +182 -0
- data/lib/http/uri.rb +376 -0
- data/lib/http/version.rb +6 -0
- data/lib/http.rb +36 -0
- data/sig/deps.rbs +122 -0
- data/sig/http.rbs +1619 -0
- data/test/http/base64_test.rb +28 -0
- data/test/http/client_test.rb +739 -0
- data/test/http/connection_test.rb +1533 -0
- data/test/http/content_type_test.rb +190 -0
- data/test/http/errors_test.rb +28 -0
- data/test/http/feature_test.rb +49 -0
- data/test/http/features/auto_deflate_test.rb +317 -0
- data/test/http/features/auto_inflate_test.rb +213 -0
- data/test/http/features/caching_test.rb +942 -0
- data/test/http/features/digest_auth_test.rb +996 -0
- data/test/http/features/instrumentation_test.rb +246 -0
- data/test/http/features/logging_test.rb +654 -0
- data/test/http/features/normalize_uri_test.rb +41 -0
- data/test/http/features/raise_error_test.rb +77 -0
- data/test/http/form_data/composite_io_test.rb +215 -0
- data/test/http/form_data/file_test.rb +255 -0
- data/test/http/form_data/fixtures/the-http-gem.info +1 -0
- data/test/http/form_data/multipart_test.rb +303 -0
- data/test/http/form_data/part_test.rb +90 -0
- data/test/http/form_data/urlencoded_test.rb +164 -0
- data/test/http/form_data_test.rb +232 -0
- data/test/http/headers/normalizer_test.rb +93 -0
- data/test/http/headers_test.rb +888 -0
- data/test/http/mime_type/json_test.rb +39 -0
- data/test/http/mime_type_test.rb +150 -0
- data/test/http/options/base_uri_test.rb +148 -0
- data/test/http/options/body_test.rb +21 -0
- data/test/http/options/features_test.rb +38 -0
- data/test/http/options/form_test.rb +21 -0
- data/test/http/options/headers_test.rb +32 -0
- data/test/http/options/json_test.rb +21 -0
- data/test/http/options/merge_test.rb +78 -0
- data/test/http/options/new_test.rb +37 -0
- data/test/http/options/proxy_test.rb +32 -0
- data/test/http/options_test.rb +575 -0
- data/test/http/redirector_test.rb +639 -0
- data/test/http/request/body_test.rb +318 -0
- data/test/http/request/builder_test.rb +623 -0
- data/test/http/request/writer_test.rb +391 -0
- data/test/http/request_test.rb +1733 -0
- data/test/http/response/body_test.rb +292 -0
- data/test/http/response/parser_test.rb +105 -0
- data/test/http/response/status_test.rb +322 -0
- data/test/http/response_test.rb +502 -0
- data/test/http/retriable/delay_calculator_test.rb +194 -0
- data/test/http/retriable/errors_test.rb +71 -0
- data/test/http/retriable/performer_test.rb +551 -0
- data/test/http/session_test.rb +424 -0
- data/test/http/timeout/global_test.rb +239 -0
- data/test/http/timeout/null_test.rb +218 -0
- data/test/http/timeout/per_operation_test.rb +220 -0
- data/test/http/uri/normalizer_test.rb +89 -0
- data/test/http/uri_test.rb +1140 -0
- data/test/http/version_test.rb +15 -0
- data/test/http_test.rb +818 -0
- data/test/regression_tests.rb +27 -0
- data/test/support/capture_warning.rb +10 -0
- data/test/support/dummy_server/encoding_routes.rb +47 -0
- data/test/support/dummy_server/routes.rb +201 -0
- data/test/support/dummy_server/servlet.rb +81 -0
- data/test/support/dummy_server.rb +200 -0
- data/test/support/fakeio.rb +21 -0
- data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
- data/test/support/http_handling_shared/timeout_tests.rb +134 -0
- data/test/support/http_handling_shared.rb +11 -0
- data/test/support/proxy_server.rb +207 -0
- data/test/support/servers/runner.rb +67 -0
- data/test/support/simplecov.rb +28 -0
- data/test/support/ssl_helper.rb +108 -0
- data/test/test_helper.rb +38 -0
- metadata +218 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module HTTP
|
|
7
|
+
# Namespace for timeout handlers
|
|
8
|
+
module Timeout
|
|
9
|
+
# Base timeout handler with no timeout enforcement
|
|
10
|
+
class Null
|
|
11
|
+
# Whether TCPSocket natively supports connect_timeout and
|
|
12
|
+
# Happy Eyeballs (RFC 8305). Available in Ruby 3.4+.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
NATIVE_CONNECT_TIMEOUT = RUBY_VERSION >= "3.4"
|
|
16
|
+
|
|
17
|
+
# Timeout configuration options
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# timeout.options # => {read_timeout: 5}
|
|
21
|
+
#
|
|
22
|
+
# @return [Hash] timeout options
|
|
23
|
+
# @api public
|
|
24
|
+
attr_reader :options
|
|
25
|
+
|
|
26
|
+
# The underlying socket
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# timeout.socket
|
|
30
|
+
#
|
|
31
|
+
# @return [Object] the underlying socket
|
|
32
|
+
# @api public
|
|
33
|
+
attr_reader :socket
|
|
34
|
+
|
|
35
|
+
# Initializes the null timeout handler
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# HTTP::Timeout::Null.new(read_timeout: 5)
|
|
39
|
+
#
|
|
40
|
+
# @param [Numeric, nil] read_timeout Read timeout in seconds
|
|
41
|
+
# @param [Numeric, nil] write_timeout Write timeout in seconds
|
|
42
|
+
# @param [Numeric, nil] connect_timeout Connect timeout in seconds
|
|
43
|
+
# @param [Numeric, nil] global_timeout Global timeout in seconds
|
|
44
|
+
# @api public
|
|
45
|
+
# @return [HTTP::Timeout::Null]
|
|
46
|
+
def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil, global_timeout: nil)
|
|
47
|
+
@options = { read_timeout: read_timeout, write_timeout: write_timeout,
|
|
48
|
+
connect_timeout: connect_timeout, global_timeout: global_timeout }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Connects to a socket
|
|
52
|
+
#
|
|
53
|
+
# @example
|
|
54
|
+
# timeout.connect(TCPSocket, "example.com", 80)
|
|
55
|
+
#
|
|
56
|
+
# @param [Class] socket_class
|
|
57
|
+
# @param [String] host
|
|
58
|
+
# @param [Integer] port
|
|
59
|
+
# @param [Boolean] nodelay
|
|
60
|
+
# @api public
|
|
61
|
+
# @return [void]
|
|
62
|
+
def connect(socket_class, host, port, nodelay: false)
|
|
63
|
+
@socket = open_socket(socket_class, host, port)
|
|
64
|
+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Starts a SSL connection on a socket
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# timeout.connect_ssl
|
|
71
|
+
#
|
|
72
|
+
# @api public
|
|
73
|
+
# @return [void]
|
|
74
|
+
def connect_ssl
|
|
75
|
+
@socket.connect
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Closes the underlying socket
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# timeout.close
|
|
82
|
+
#
|
|
83
|
+
# @api public
|
|
84
|
+
# @return [void]
|
|
85
|
+
def close
|
|
86
|
+
@socket&.close
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Checks whether the socket is closed
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# timeout.closed?
|
|
93
|
+
#
|
|
94
|
+
# @api public
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def closed?
|
|
97
|
+
@socket&.closed?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Configures the SSL connection and starts it
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# timeout.start_tls("example.com", ssl_class, ssl_ctx)
|
|
104
|
+
#
|
|
105
|
+
# @param [String] host
|
|
106
|
+
# @param [Class] ssl_socket_class
|
|
107
|
+
# @param [OpenSSL::SSL::SSLContext] ssl_context
|
|
108
|
+
# @api public
|
|
109
|
+
# @return [void]
|
|
110
|
+
def start_tls(host, ssl_socket_class, ssl_context)
|
|
111
|
+
@socket = ssl_socket_class.new(socket, ssl_context)
|
|
112
|
+
@socket.hostname = host if @socket.respond_to? :hostname=
|
|
113
|
+
@socket.sync_close = true if @socket.respond_to? :sync_close=
|
|
114
|
+
|
|
115
|
+
connect_ssl
|
|
116
|
+
|
|
117
|
+
return unless ssl_context.verify_mode == OpenSSL::SSL::VERIFY_PEER
|
|
118
|
+
return if ssl_context.respond_to?(:verify_hostname) && !ssl_context.verify_hostname
|
|
119
|
+
|
|
120
|
+
@socket.post_connection_check(host)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Read from the socket
|
|
124
|
+
#
|
|
125
|
+
# @example
|
|
126
|
+
# timeout.readpartial(1024)
|
|
127
|
+
#
|
|
128
|
+
# @param [Integer] size
|
|
129
|
+
# @param [String, nil] buffer
|
|
130
|
+
# @api public
|
|
131
|
+
# @return [String, :eof]
|
|
132
|
+
def readpartial(size, buffer = nil)
|
|
133
|
+
@socket.readpartial(size, buffer)
|
|
134
|
+
rescue EOFError
|
|
135
|
+
:eof
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Write to the socket
|
|
139
|
+
#
|
|
140
|
+
# @example
|
|
141
|
+
# timeout.write("GET / HTTP/1.1")
|
|
142
|
+
#
|
|
143
|
+
# @param [String] data
|
|
144
|
+
# @api public
|
|
145
|
+
# @return [Integer]
|
|
146
|
+
def write(data)
|
|
147
|
+
@socket.write(data)
|
|
148
|
+
end
|
|
149
|
+
alias << write
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Retries reading on wait readable
|
|
154
|
+
#
|
|
155
|
+
# @api private
|
|
156
|
+
# @return [Object]
|
|
157
|
+
def rescue_readable(timeout = read_timeout)
|
|
158
|
+
yield
|
|
159
|
+
rescue IO::WaitReadable
|
|
160
|
+
retry if @socket.to_io.wait_readable(timeout)
|
|
161
|
+
raise TimeoutError, "Read timed out after #{timeout} seconds"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Retries writing on wait writable
|
|
165
|
+
#
|
|
166
|
+
# @api private
|
|
167
|
+
# @return [Object]
|
|
168
|
+
def rescue_writable(timeout = write_timeout)
|
|
169
|
+
yield
|
|
170
|
+
rescue IO::WaitWritable
|
|
171
|
+
retry if @socket.to_io.wait_writable(timeout)
|
|
172
|
+
raise TimeoutError, "Write timed out after #{timeout} seconds"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Opens a TCP socket, using native connect_timeout when available
|
|
176
|
+
#
|
|
177
|
+
# On Ruby 3.4+ with TCPSocket, passes connect_timeout natively to
|
|
178
|
+
# enable proper Happy Eyeballs (RFC 8305) support. Falls back to
|
|
179
|
+
# Timeout.timeout on older Rubies or with custom socket classes.
|
|
180
|
+
#
|
|
181
|
+
# @param [Class] socket_class socket class to create
|
|
182
|
+
# @param [String] host remote hostname
|
|
183
|
+
# @param [Integer] port remote port
|
|
184
|
+
# @param [Numeric, nil] connect_timeout timeout in seconds
|
|
185
|
+
# @return [Object] the connected socket
|
|
186
|
+
# @api private
|
|
187
|
+
def open_socket(socket_class, host, port, connect_timeout: nil)
|
|
188
|
+
if connect_timeout
|
|
189
|
+
::Timeout.timeout(connect_timeout, ConnectTimeoutError) do
|
|
190
|
+
open_with_timeout(socket_class, host, port, connect_timeout)
|
|
191
|
+
end
|
|
192
|
+
else
|
|
193
|
+
socket_class.open(host, port)
|
|
194
|
+
end
|
|
195
|
+
rescue IO::TimeoutError
|
|
196
|
+
raise ConnectTimeoutError, "Connect timed out after #{connect_timeout} seconds"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Opens a socket, passing connect_timeout natively when supported
|
|
200
|
+
#
|
|
201
|
+
# @param [Class] socket_class socket class to create
|
|
202
|
+
# @param [String] host remote hostname
|
|
203
|
+
# @param [Integer] port remote port
|
|
204
|
+
# @param [Numeric] connect_timeout timeout in seconds
|
|
205
|
+
# @return [Object] the connected socket
|
|
206
|
+
# @api private
|
|
207
|
+
def open_with_timeout(socket_class, host, port, connect_timeout)
|
|
208
|
+
if native_timeout?(socket_class)
|
|
209
|
+
socket_class.open(host, port, connect_timeout: connect_timeout)
|
|
210
|
+
else
|
|
211
|
+
socket_class.open(host, port)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whether the socket class supports native connect_timeout
|
|
216
|
+
#
|
|
217
|
+
# @param [Class] socket_class socket class to check
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
# @api private
|
|
220
|
+
def native_timeout?(socket_class)
|
|
221
|
+
NATIVE_CONNECT_TIMEOUT && socket_class.is_a?(Class) && socket_class <= TCPSocket
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
require "http/timeout/null"
|
|
6
|
+
|
|
7
|
+
module HTTP
|
|
8
|
+
module Timeout
|
|
9
|
+
# Timeout handler with separate timeouts for connect, read, and write
|
|
10
|
+
class PerOperation < Null
|
|
11
|
+
# Mapping of shorthand option keys to their full forms
|
|
12
|
+
KEYS = %i[read write connect].to_h { |k| [k, :"#{k}_timeout"] }.freeze
|
|
13
|
+
|
|
14
|
+
# Normalize and validate timeout options
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# PerOperation.normalize_options(read: 5, write: 3)
|
|
18
|
+
#
|
|
19
|
+
# @param [Hash] options timeout options with short or long keys
|
|
20
|
+
# @return [Hash] normalized options with long keys
|
|
21
|
+
# @raise [ArgumentError] if options are invalid
|
|
22
|
+
# @api public
|
|
23
|
+
def self.normalize_options(options)
|
|
24
|
+
remaining = options.dup
|
|
25
|
+
normalized = {} #: Hash[Symbol, Numeric]
|
|
26
|
+
|
|
27
|
+
KEYS.each do |short, long|
|
|
28
|
+
next if !remaining.key?(short) && !remaining.key?(long)
|
|
29
|
+
|
|
30
|
+
normalized[long] = resolve_timeout_value!(remaining, short, long)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "unknown timeout options: #{remaining.keys.join(', ')}" unless remaining.empty?
|
|
34
|
+
raise ArgumentError, "no timeout options given" if normalized.empty?
|
|
35
|
+
|
|
36
|
+
normalized
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Extract and validate global timeout from options hash
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# extract_global_timeout!({global: 60, read: 5})
|
|
43
|
+
#
|
|
44
|
+
# @param [Hash] options mutable options hash (global key is deleted if found)
|
|
45
|
+
# @return [Numeric, nil] the global timeout value, or nil if not present
|
|
46
|
+
# @raise [ArgumentError] if both forms given or value is not numeric
|
|
47
|
+
# @api private
|
|
48
|
+
private_class_method def self.extract_global_timeout!(options)
|
|
49
|
+
return unless options.key?(:global) || options.key?(:global_timeout)
|
|
50
|
+
|
|
51
|
+
resolve_timeout_value!(options, :global, :global_timeout)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve a single timeout value from the options hash
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# resolve_timeout_value!({read: 5}, :read, :read_timeout)
|
|
58
|
+
#
|
|
59
|
+
# @param [Hash] options mutable options hash (keys are deleted as consumed)
|
|
60
|
+
# @param [Symbol] short short key name (e.g. :read)
|
|
61
|
+
# @param [Symbol] long long key name (e.g. :read_timeout)
|
|
62
|
+
# @return [Numeric] the timeout value
|
|
63
|
+
# @raise [ArgumentError] if both forms given or value is not numeric
|
|
64
|
+
# @api private
|
|
65
|
+
private_class_method def self.resolve_timeout_value!(options, short, long)
|
|
66
|
+
raise ArgumentError, "can't pass both #{short} and #{long}" if options.key?(short) && options.key?(long)
|
|
67
|
+
|
|
68
|
+
value = options.delete(options.key?(long) ? long : short)
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "#{long} must be numeric" unless value.is_a?(Numeric)
|
|
71
|
+
|
|
72
|
+
value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Initializes per-operation timeout with options
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# HTTP::Timeout::PerOperation.new(read_timeout: 5)
|
|
79
|
+
#
|
|
80
|
+
# @param [Numeric, nil] read_timeout Read timeout in seconds (nil for no timeout)
|
|
81
|
+
# @param [Numeric, nil] write_timeout Write timeout in seconds (nil for no timeout)
|
|
82
|
+
# @param [Numeric, nil] connect_timeout Connect timeout in seconds (nil for no timeout)
|
|
83
|
+
# @api public
|
|
84
|
+
# @return [HTTP::Timeout::PerOperation]
|
|
85
|
+
def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil)
|
|
86
|
+
super
|
|
87
|
+
|
|
88
|
+
@read_timeout = read_timeout
|
|
89
|
+
@write_timeout = write_timeout
|
|
90
|
+
@connect_timeout = connect_timeout
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Connects to a socket with connect timeout
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# timeout.connect(TCPSocket, "example.com", 80)
|
|
97
|
+
#
|
|
98
|
+
# @param [Class] socket_class
|
|
99
|
+
# @param [String] host
|
|
100
|
+
# @param [Integer] port
|
|
101
|
+
# @param [Boolean] nodelay
|
|
102
|
+
# @api public
|
|
103
|
+
# @return [void]
|
|
104
|
+
def connect(socket_class, host, port, nodelay: false)
|
|
105
|
+
@socket = open_socket(socket_class, host, port, connect_timeout: @connect_timeout)
|
|
106
|
+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Starts an SSL connection with connect timeout
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# timeout.connect_ssl
|
|
113
|
+
#
|
|
114
|
+
# @api public
|
|
115
|
+
# @return [void]
|
|
116
|
+
def connect_ssl
|
|
117
|
+
rescue_readable(@connect_timeout) do
|
|
118
|
+
rescue_writable(@connect_timeout) do
|
|
119
|
+
@socket.connect_nonblock
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# I/O wait result symbols returned by non-blocking operations
|
|
125
|
+
WAIT_RESULTS = %i[wait_readable wait_writable].freeze
|
|
126
|
+
|
|
127
|
+
# Read data from the socket
|
|
128
|
+
#
|
|
129
|
+
# @example
|
|
130
|
+
# timeout.readpartial(1024)
|
|
131
|
+
#
|
|
132
|
+
# @param [Integer] size
|
|
133
|
+
# @param [String, nil] buffer
|
|
134
|
+
# @api public
|
|
135
|
+
# @return [String, :eof]
|
|
136
|
+
def readpartial(size, buffer = nil)
|
|
137
|
+
timeout = false
|
|
138
|
+
loop do
|
|
139
|
+
result = @socket.read_nonblock(size, buffer, exception: false)
|
|
140
|
+
|
|
141
|
+
return :eof if result.nil?
|
|
142
|
+
return result unless WAIT_RESULTS.include?(result)
|
|
143
|
+
|
|
144
|
+
raise TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout
|
|
145
|
+
|
|
146
|
+
# marking the socket for timeout. Why is this not being raised immediately?
|
|
147
|
+
# it seems there is some race-condition on the network level between calling
|
|
148
|
+
# #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
|
|
149
|
+
# for reads, and when waiting for x seconds, it returns nil suddenly without completing
|
|
150
|
+
# the x seconds. In a normal case this would be a timeout on wait/read, but it can
|
|
151
|
+
# also mean that the socket has been closed by the server. Therefore we "mark" the
|
|
152
|
+
# socket for timeout and try to read more bytes. If it returns :eof, it's all good, no
|
|
153
|
+
# timeout. Else, the first timeout was a proper timeout.
|
|
154
|
+
# This hack has to be done because io/wait#wait_readable doesn't provide a value for when
|
|
155
|
+
# the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
|
|
156
|
+
timeout = true unless wait_for_io(result, @read_timeout)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Write data to the socket
|
|
161
|
+
#
|
|
162
|
+
# @example
|
|
163
|
+
# timeout.write("GET / HTTP/1.1")
|
|
164
|
+
#
|
|
165
|
+
# @param [String] data
|
|
166
|
+
# @api public
|
|
167
|
+
# @return [Integer]
|
|
168
|
+
def write(data)
|
|
169
|
+
timeout = false
|
|
170
|
+
loop do
|
|
171
|
+
result = @socket.write_nonblock(data, exception: false)
|
|
172
|
+
return result unless WAIT_RESULTS.include?(result)
|
|
173
|
+
|
|
174
|
+
raise TimeoutError, "Write timed out after #{@write_timeout} seconds" if timeout
|
|
175
|
+
|
|
176
|
+
timeout = true unless wait_for_io(result, @write_timeout)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# Waits for I/O readiness based on the result type
|
|
183
|
+
#
|
|
184
|
+
# @param [Symbol] result the I/O wait type (:wait_readable or :wait_writable)
|
|
185
|
+
# @param [Numeric, nil] timeout per-operation timeout limit
|
|
186
|
+
# @return [Object, nil] the socket if ready, nil on timeout
|
|
187
|
+
# @api private
|
|
188
|
+
def wait_for_io(result, timeout)
|
|
189
|
+
if result == :wait_readable
|
|
190
|
+
@socket.to_io.wait_readable(timeout)
|
|
191
|
+
else
|
|
192
|
+
@socket.to_io.wait_writable(timeout)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HTTP
|
|
4
|
+
# URI normalization and dot-segment removal
|
|
5
|
+
class URI
|
|
6
|
+
# Default URI normalizer
|
|
7
|
+
# @private
|
|
8
|
+
NORMALIZER = lambda do |uri|
|
|
9
|
+
uri = HTTP::URI.parse uri
|
|
10
|
+
scheme = uri.scheme&.downcase
|
|
11
|
+
host = uri.normalized_host
|
|
12
|
+
host = "[#{host}]" if host&.include?(":")
|
|
13
|
+
default_port = scheme == HTTPS_SCHEME ? 443 : 80
|
|
14
|
+
|
|
15
|
+
HTTP::URI.new(
|
|
16
|
+
scheme: scheme,
|
|
17
|
+
user: uri.user,
|
|
18
|
+
password: uri.password,
|
|
19
|
+
host: host,
|
|
20
|
+
port: (uri.port == default_port ? nil : uri.port),
|
|
21
|
+
path: uri.path.empty? ? "/" : percent_encode(remove_dot_segments(uri.path)),
|
|
22
|
+
query: percent_encode(uri.query),
|
|
23
|
+
fragment: uri.fragment
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Standalone dot segments that terminate the algorithm
|
|
28
|
+
# @private
|
|
29
|
+
DOT_SEGMENTS = %w[. ..].freeze
|
|
30
|
+
|
|
31
|
+
# Matches "/." followed by "/" or end-of-string
|
|
32
|
+
# @private
|
|
33
|
+
SINGLE_DOT_SEGMENT = %r{\A/\.(?:/|\z)}
|
|
34
|
+
|
|
35
|
+
# Matches "/.." followed by "/" or end-of-string
|
|
36
|
+
# @private
|
|
37
|
+
DOUBLE_DOT_SEGMENT = %r{\A/\.\.(?:/|\z)}
|
|
38
|
+
|
|
39
|
+
# Matches the last segment in a path (everything after the final "/")
|
|
40
|
+
# @private
|
|
41
|
+
LAST_SEGMENT = %r{/[^/]*\z}
|
|
42
|
+
|
|
43
|
+
# Matches the first path segment, with or without a leading "/"
|
|
44
|
+
# @private
|
|
45
|
+
FIRST_SEGMENT = %r{\A/?[^/]*}
|
|
46
|
+
|
|
47
|
+
# Remove dot segments from a URI path per RFC 3986 Section 5.2.4
|
|
48
|
+
#
|
|
49
|
+
# @param [String] path URI path to normalize
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
52
|
+
# @return [String] path with dot segments removed
|
|
53
|
+
def self.remove_dot_segments(path)
|
|
54
|
+
input = path.dup
|
|
55
|
+
output = +""
|
|
56
|
+
until input.empty?
|
|
57
|
+
reduce_dot_segment(input, output) unless
|
|
58
|
+
input.delete_prefix!("../") || input.delete_prefix!("./") ||
|
|
59
|
+
input.sub!(SINGLE_DOT_SEGMENT, "/")
|
|
60
|
+
end
|
|
61
|
+
output
|
|
62
|
+
end
|
|
63
|
+
private_class_method :remove_dot_segments
|
|
64
|
+
|
|
65
|
+
# Process a single dot-segment removal step per RFC 3986 Section 5.2.4
|
|
66
|
+
#
|
|
67
|
+
# @param [String] input remaining path input (mutated)
|
|
68
|
+
# @param [String] output accumulated result (mutated)
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
# @return [void]
|
|
72
|
+
private_class_method def self.reduce_dot_segment(input, output)
|
|
73
|
+
if input.sub!(DOUBLE_DOT_SEGMENT, "/")
|
|
74
|
+
output.sub!(LAST_SEGMENT, "")
|
|
75
|
+
elsif DOT_SEGMENTS.include?(input)
|
|
76
|
+
input.clear
|
|
77
|
+
else
|
|
78
|
+
output << input.slice!(FIRST_SEGMENT) # steep:ignore
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HTTP
|
|
4
|
+
# Class methods and private helpers for URI parsing and host processing
|
|
5
|
+
class URI
|
|
6
|
+
# Parse the given URI string, returning an HTTP::URI object
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# HTTP::URI.parse("http://example.com/path")
|
|
10
|
+
#
|
|
11
|
+
# @param [HTTP::URI, String, #to_str] uri to parse
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
# @return [HTTP::URI] new URI instance
|
|
15
|
+
def self.parse(uri)
|
|
16
|
+
return uri if uri.is_a?(self)
|
|
17
|
+
raise InvalidError, "invalid URI: nil" if uri.nil?
|
|
18
|
+
|
|
19
|
+
uri_string = begin
|
|
20
|
+
String(uri)
|
|
21
|
+
rescue TypeError, NoMethodError
|
|
22
|
+
raise InvalidError, "invalid URI: #{uri.inspect}"
|
|
23
|
+
end
|
|
24
|
+
new(**parse_components(uri_string))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Encodes key/value pairs as application/x-www-form-urlencoded
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# HTTP::URI.form_encode(foo: "bar")
|
|
31
|
+
#
|
|
32
|
+
# @param [#to_hash, #to_ary] form_values to encode
|
|
33
|
+
# @param [TrueClass, FalseClass] sort should key/value pairs be sorted first?
|
|
34
|
+
#
|
|
35
|
+
# @api public
|
|
36
|
+
# @return [String] encoded value
|
|
37
|
+
def self.form_encode(form_values, sort: false)
|
|
38
|
+
return ::URI.encode_www_form(form_values) unless sort
|
|
39
|
+
|
|
40
|
+
::URI.encode_www_form(form_values.sort_by { |k, _| String(k) })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Percent-encode matching characters in a string
|
|
44
|
+
#
|
|
45
|
+
# @param [String] string raw string
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
# @return [String] encoded value
|
|
49
|
+
def self.percent_encode(string)
|
|
50
|
+
string&.gsub(PERCENT_ENCODE) do |substr|
|
|
51
|
+
substr.bytes.map { |c| format("%%%02X", c) }.join
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Loads the addressable gem on first use
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @return [void]
|
|
59
|
+
# @raise [LoadError] if addressable gem is not installed
|
|
60
|
+
def self.require_addressable
|
|
61
|
+
return if defined?(@addressable_loaded)
|
|
62
|
+
|
|
63
|
+
require "addressable/uri"
|
|
64
|
+
@addressable_loaded = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Convert a hostname to ASCII via IDNA (requires addressable)
|
|
68
|
+
#
|
|
69
|
+
# @param [String] host hostname to encode
|
|
70
|
+
# @api private
|
|
71
|
+
# @return [String] ASCII-encoded hostname
|
|
72
|
+
def self.idna_to_ascii(host)
|
|
73
|
+
return host if host.ascii_only?
|
|
74
|
+
|
|
75
|
+
require_addressable
|
|
76
|
+
Addressable::IDNA.to_ascii(host) # steep:ignore
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Serialize the authority section of a URI (userinfo + host + port)
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @return [String] authority component
|
|
85
|
+
def authority_string
|
|
86
|
+
str = +"//"
|
|
87
|
+
if (user = @user)
|
|
88
|
+
str << user
|
|
89
|
+
str << ":#{@password}" if @password
|
|
90
|
+
str << "@"
|
|
91
|
+
end
|
|
92
|
+
str << @raw_host # steep:ignore
|
|
93
|
+
str << ":#{@port}" if @port
|
|
94
|
+
str
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Adds or removes IPv6 brackets from a host
|
|
98
|
+
#
|
|
99
|
+
# @param [String] raw_host
|
|
100
|
+
# @param [Boolean] brackets
|
|
101
|
+
# @api private
|
|
102
|
+
# @return [String] Host with IPv6 address brackets added or removed
|
|
103
|
+
def process_ipv6_brackets(raw_host, brackets: false)
|
|
104
|
+
return unless raw_host
|
|
105
|
+
|
|
106
|
+
stripped = raw_host.delete_prefix("[").delete_suffix("]")
|
|
107
|
+
ip = IPAddr.new(stripped)
|
|
108
|
+
|
|
109
|
+
if ip.ipv6?
|
|
110
|
+
brackets ? "[#{ip}]" : ip.to_s
|
|
111
|
+
else
|
|
112
|
+
raw_host
|
|
113
|
+
end
|
|
114
|
+
rescue IPAddr::Error
|
|
115
|
+
raw_host
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Normalize a host for comparison and lookup
|
|
119
|
+
#
|
|
120
|
+
# Percent-decodes, strips trailing dot, lowercases, and IDN-encodes
|
|
121
|
+
# non-ASCII hostnames.
|
|
122
|
+
#
|
|
123
|
+
# @param [String, nil] host the host to normalize
|
|
124
|
+
# @api private
|
|
125
|
+
# @return [String, nil] normalized host
|
|
126
|
+
def normalize_host(host)
|
|
127
|
+
return nil unless host
|
|
128
|
+
|
|
129
|
+
h = host.gsub(/%\h{2}/) { |match| match.delete_prefix("%").to_i(16).chr }
|
|
130
|
+
h = h.delete_suffix(".")
|
|
131
|
+
h = h.downcase
|
|
132
|
+
self.class.idna_to_ascii(h)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Parse a URI string into component parts
|
|
136
|
+
#
|
|
137
|
+
# Uses stdlib for printable-ASCII URIs (faster), falling back to
|
|
138
|
+
# Addressable for non-ASCII or when stdlib rejects the input.
|
|
139
|
+
#
|
|
140
|
+
# @param [String] uri_string the URI to parse
|
|
141
|
+
# @api private
|
|
142
|
+
# @return [Hash] URI components
|
|
143
|
+
private_class_method def self.parse_components(uri_string)
|
|
144
|
+
return parse_with_addressable(uri_string) if uri_string.match?(NEEDS_ADDRESSABLE)
|
|
145
|
+
|
|
146
|
+
parse_with_stdlib(uri_string) || parse_with_addressable(uri_string)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Parse an ASCII URI using stdlib
|
|
150
|
+
#
|
|
151
|
+
# @param [String] uri_string the URI to parse
|
|
152
|
+
# @api private
|
|
153
|
+
# @return [Hash, nil] URI components, or nil if stdlib rejects the input
|
|
154
|
+
private_class_method def self.parse_with_stdlib(uri_string)
|
|
155
|
+
parsed = ::URI.parse(uri_string)
|
|
156
|
+
# stdlib always returns a port (defaulting to scheme's default);
|
|
157
|
+
# only store it when explicitly specified
|
|
158
|
+
port = parsed.port
|
|
159
|
+
port = nil if port.eql?(parsed.default_port)
|
|
160
|
+
{ scheme: parsed.scheme, user: parsed.user, password: parsed.password,
|
|
161
|
+
host: parsed.host, port: port, path: parsed.path,
|
|
162
|
+
query: parsed.query, fragment: parsed.fragment }
|
|
163
|
+
rescue ::URI::InvalidURIError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Parse a non-ASCII URI using Addressable
|
|
168
|
+
#
|
|
169
|
+
# @param [String] uri_string the URI to parse
|
|
170
|
+
# @api private
|
|
171
|
+
# @return [Hash] URI components
|
|
172
|
+
private_class_method def self.parse_with_addressable(uri_string)
|
|
173
|
+
require_addressable
|
|
174
|
+
parsed = Addressable::URI.parse(uri_string) # steep:ignore
|
|
175
|
+
{ scheme: parsed.scheme, user: parsed.user, password: parsed.password,
|
|
176
|
+
host: parsed.host, port: parsed.port, path: parsed.path,
|
|
177
|
+
query: parsed.query, fragment: parsed.fragment }
|
|
178
|
+
rescue Addressable::URI::InvalidURIError # steep:ignore
|
|
179
|
+
raise InvalidError, "invalid URI: #{uri_string.inspect}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|