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.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +267 -0
  3. data/CONTRIBUTING.md +26 -0
  4. data/LICENSE.txt +20 -0
  5. data/README.md +263 -0
  6. data/SECURITY.md +17 -0
  7. data/UPGRADING.md +491 -0
  8. data/http.gemspec +48 -0
  9. data/lib/http/base64.rb +22 -0
  10. data/lib/http/chainable/helpers.rb +62 -0
  11. data/lib/http/chainable/verbs.rb +136 -0
  12. data/lib/http/chainable.rb +377 -0
  13. data/lib/http/client.rb +230 -0
  14. data/lib/http/connection/internals.rb +141 -0
  15. data/lib/http/connection.rb +265 -0
  16. data/lib/http/content_type.rb +89 -0
  17. data/lib/http/errors.rb +67 -0
  18. data/lib/http/feature.rb +86 -0
  19. data/lib/http/features/auto_deflate.rb +230 -0
  20. data/lib/http/features/auto_inflate.rb +64 -0
  21. data/lib/http/features/caching/entry.rb +178 -0
  22. data/lib/http/features/caching/in_memory_store.rb +63 -0
  23. data/lib/http/features/caching.rb +216 -0
  24. data/lib/http/features/digest_auth.rb +234 -0
  25. data/lib/http/features/instrumentation.rb +149 -0
  26. data/lib/http/features/logging.rb +231 -0
  27. data/lib/http/features/normalize_uri.rb +34 -0
  28. data/lib/http/features/raise_error.rb +37 -0
  29. data/lib/http/form_data/composite_io.rb +106 -0
  30. data/lib/http/form_data/file.rb +95 -0
  31. data/lib/http/form_data/multipart/param.rb +62 -0
  32. data/lib/http/form_data/multipart.rb +106 -0
  33. data/lib/http/form_data/part.rb +52 -0
  34. data/lib/http/form_data/readable.rb +58 -0
  35. data/lib/http/form_data/urlencoded.rb +175 -0
  36. data/lib/http/form_data/version.rb +8 -0
  37. data/lib/http/form_data.rb +102 -0
  38. data/lib/http/headers/known.rb +90 -0
  39. data/lib/http/headers/normalizer.rb +50 -0
  40. data/lib/http/headers.rb +343 -0
  41. data/lib/http/mime_type/adapter.rb +43 -0
  42. data/lib/http/mime_type/json.rb +41 -0
  43. data/lib/http/mime_type.rb +96 -0
  44. data/lib/http/options/definitions.rb +189 -0
  45. data/lib/http/options.rb +241 -0
  46. data/lib/http/redirector.rb +157 -0
  47. data/lib/http/request/body.rb +181 -0
  48. data/lib/http/request/builder.rb +184 -0
  49. data/lib/http/request/proxy.rb +83 -0
  50. data/lib/http/request/writer.rb +186 -0
  51. data/lib/http/request.rb +375 -0
  52. data/lib/http/response/body.rb +172 -0
  53. data/lib/http/response/inflater.rb +60 -0
  54. data/lib/http/response/parser.rb +223 -0
  55. data/lib/http/response/status/reasons.rb +79 -0
  56. data/lib/http/response/status.rb +263 -0
  57. data/lib/http/response.rb +350 -0
  58. data/lib/http/retriable/delay_calculator.rb +91 -0
  59. data/lib/http/retriable/errors.rb +35 -0
  60. data/lib/http/retriable/performer.rb +197 -0
  61. data/lib/http/session.rb +280 -0
  62. data/lib/http/timeout/global.rb +229 -0
  63. data/lib/http/timeout/null.rb +225 -0
  64. data/lib/http/timeout/per_operation.rb +197 -0
  65. data/lib/http/uri/normalizer.rb +82 -0
  66. data/lib/http/uri/parsing.rb +182 -0
  67. data/lib/http/uri.rb +376 -0
  68. data/lib/http/version.rb +6 -0
  69. data/lib/http.rb +36 -0
  70. data/sig/deps.rbs +122 -0
  71. data/sig/http.rbs +1619 -0
  72. data/test/http/base64_test.rb +28 -0
  73. data/test/http/client_test.rb +739 -0
  74. data/test/http/connection_test.rb +1533 -0
  75. data/test/http/content_type_test.rb +190 -0
  76. data/test/http/errors_test.rb +28 -0
  77. data/test/http/feature_test.rb +49 -0
  78. data/test/http/features/auto_deflate_test.rb +317 -0
  79. data/test/http/features/auto_inflate_test.rb +213 -0
  80. data/test/http/features/caching_test.rb +942 -0
  81. data/test/http/features/digest_auth_test.rb +996 -0
  82. data/test/http/features/instrumentation_test.rb +246 -0
  83. data/test/http/features/logging_test.rb +654 -0
  84. data/test/http/features/normalize_uri_test.rb +41 -0
  85. data/test/http/features/raise_error_test.rb +77 -0
  86. data/test/http/form_data/composite_io_test.rb +215 -0
  87. data/test/http/form_data/file_test.rb +255 -0
  88. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  89. data/test/http/form_data/multipart_test.rb +303 -0
  90. data/test/http/form_data/part_test.rb +90 -0
  91. data/test/http/form_data/urlencoded_test.rb +164 -0
  92. data/test/http/form_data_test.rb +232 -0
  93. data/test/http/headers/normalizer_test.rb +93 -0
  94. data/test/http/headers_test.rb +888 -0
  95. data/test/http/mime_type/json_test.rb +39 -0
  96. data/test/http/mime_type_test.rb +150 -0
  97. data/test/http/options/base_uri_test.rb +148 -0
  98. data/test/http/options/body_test.rb +21 -0
  99. data/test/http/options/features_test.rb +38 -0
  100. data/test/http/options/form_test.rb +21 -0
  101. data/test/http/options/headers_test.rb +32 -0
  102. data/test/http/options/json_test.rb +21 -0
  103. data/test/http/options/merge_test.rb +78 -0
  104. data/test/http/options/new_test.rb +37 -0
  105. data/test/http/options/proxy_test.rb +32 -0
  106. data/test/http/options_test.rb +575 -0
  107. data/test/http/redirector_test.rb +639 -0
  108. data/test/http/request/body_test.rb +318 -0
  109. data/test/http/request/builder_test.rb +623 -0
  110. data/test/http/request/writer_test.rb +391 -0
  111. data/test/http/request_test.rb +1733 -0
  112. data/test/http/response/body_test.rb +292 -0
  113. data/test/http/response/parser_test.rb +105 -0
  114. data/test/http/response/status_test.rb +322 -0
  115. data/test/http/response_test.rb +502 -0
  116. data/test/http/retriable/delay_calculator_test.rb +194 -0
  117. data/test/http/retriable/errors_test.rb +71 -0
  118. data/test/http/retriable/performer_test.rb +551 -0
  119. data/test/http/session_test.rb +424 -0
  120. data/test/http/timeout/global_test.rb +239 -0
  121. data/test/http/timeout/null_test.rb +218 -0
  122. data/test/http/timeout/per_operation_test.rb +220 -0
  123. data/test/http/uri/normalizer_test.rb +89 -0
  124. data/test/http/uri_test.rb +1140 -0
  125. data/test/http/version_test.rb +15 -0
  126. data/test/http_test.rb +818 -0
  127. data/test/regression_tests.rb +27 -0
  128. data/test/support/capture_warning.rb +10 -0
  129. data/test/support/dummy_server/encoding_routes.rb +47 -0
  130. data/test/support/dummy_server/routes.rb +201 -0
  131. data/test/support/dummy_server/servlet.rb +81 -0
  132. data/test/support/dummy_server.rb +200 -0
  133. data/test/support/fakeio.rb +21 -0
  134. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  135. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  136. data/test/support/http_handling_shared.rb +11 -0
  137. data/test/support/proxy_server.rb +207 -0
  138. data/test/support/servers/runner.rb +67 -0
  139. data/test/support/simplecov.rb +28 -0
  140. data/test/support/ssl_helper.rb +108 -0
  141. data/test/test_helper.rb +38 -0
  142. 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