http 5.3.1 → 6.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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -41
  3. data/LICENSE.txt +1 -1
  4. data/README.md +110 -13
  5. data/UPGRADING.md +491 -0
  6. data/http.gemspec +32 -29
  7. data/lib/http/base64.rb +11 -1
  8. data/lib/http/chainable/helpers.rb +62 -0
  9. data/lib/http/chainable/verbs.rb +136 -0
  10. data/lib/http/chainable.rb +232 -136
  11. data/lib/http/client.rb +158 -127
  12. data/lib/http/connection/internals.rb +141 -0
  13. data/lib/http/connection.rb +126 -97
  14. data/lib/http/content_type.rb +61 -6
  15. data/lib/http/errors.rb +25 -1
  16. data/lib/http/feature.rb +65 -5
  17. data/lib/http/features/auto_deflate.rb +124 -17
  18. data/lib/http/features/auto_inflate.rb +38 -15
  19. data/lib/http/features/caching/entry.rb +178 -0
  20. data/lib/http/features/caching/in_memory_store.rb +63 -0
  21. data/lib/http/features/caching.rb +216 -0
  22. data/lib/http/features/digest_auth.rb +234 -0
  23. data/lib/http/features/instrumentation.rb +97 -17
  24. data/lib/http/features/logging.rb +183 -5
  25. data/lib/http/features/normalize_uri.rb +17 -0
  26. data/lib/http/features/raise_error.rb +18 -3
  27. data/lib/http/form_data/composite_io.rb +106 -0
  28. data/lib/http/form_data/file.rb +95 -0
  29. data/lib/http/form_data/multipart/param.rb +62 -0
  30. data/lib/http/form_data/multipart.rb +106 -0
  31. data/lib/http/form_data/part.rb +52 -0
  32. data/lib/http/form_data/readable.rb +58 -0
  33. data/lib/http/form_data/urlencoded.rb +175 -0
  34. data/lib/http/form_data/version.rb +8 -0
  35. data/lib/http/form_data.rb +102 -0
  36. data/lib/http/headers/known.rb +3 -0
  37. data/lib/http/headers/normalizer.rb +17 -36
  38. data/lib/http/headers.rb +172 -65
  39. data/lib/http/mime_type/adapter.rb +24 -9
  40. data/lib/http/mime_type/json.rb +19 -4
  41. data/lib/http/mime_type.rb +21 -3
  42. data/lib/http/options/definitions.rb +189 -0
  43. data/lib/http/options.rb +172 -125
  44. data/lib/http/redirector.rb +80 -75
  45. data/lib/http/request/body.rb +87 -6
  46. data/lib/http/request/builder.rb +184 -0
  47. data/lib/http/request/proxy.rb +83 -0
  48. data/lib/http/request/writer.rb +76 -16
  49. data/lib/http/request.rb +214 -98
  50. data/lib/http/response/body.rb +103 -18
  51. data/lib/http/response/inflater.rb +35 -7
  52. data/lib/http/response/parser.rb +98 -4
  53. data/lib/http/response/status/reasons.rb +2 -4
  54. data/lib/http/response/status.rb +141 -31
  55. data/lib/http/response.rb +219 -61
  56. data/lib/http/retriable/delay_calculator.rb +38 -11
  57. data/lib/http/retriable/errors.rb +21 -0
  58. data/lib/http/retriable/performer.rb +82 -38
  59. data/lib/http/session.rb +280 -0
  60. data/lib/http/timeout/global.rb +147 -34
  61. data/lib/http/timeout/null.rb +155 -9
  62. data/lib/http/timeout/per_operation.rb +139 -18
  63. data/lib/http/uri/normalizer.rb +82 -0
  64. data/lib/http/uri/parsing.rb +182 -0
  65. data/lib/http/uri.rb +289 -124
  66. data/lib/http/version.rb +2 -1
  67. data/lib/http.rb +11 -2
  68. data/sig/deps.rbs +122 -0
  69. data/sig/http.rbs +1619 -0
  70. data/test/http/base64_test.rb +28 -0
  71. data/test/http/client_test.rb +739 -0
  72. data/test/http/connection_test.rb +1533 -0
  73. data/test/http/content_type_test.rb +190 -0
  74. data/test/http/errors_test.rb +28 -0
  75. data/test/http/feature_test.rb +49 -0
  76. data/test/http/features/auto_deflate_test.rb +317 -0
  77. data/test/http/features/auto_inflate_test.rb +213 -0
  78. data/test/http/features/caching_test.rb +942 -0
  79. data/test/http/features/digest_auth_test.rb +996 -0
  80. data/test/http/features/instrumentation_test.rb +246 -0
  81. data/test/http/features/logging_test.rb +654 -0
  82. data/test/http/features/normalize_uri_test.rb +41 -0
  83. data/test/http/features/raise_error_test.rb +77 -0
  84. data/test/http/form_data/composite_io_test.rb +215 -0
  85. data/test/http/form_data/file_test.rb +255 -0
  86. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  87. data/test/http/form_data/multipart_test.rb +303 -0
  88. data/test/http/form_data/part_test.rb +90 -0
  89. data/test/http/form_data/urlencoded_test.rb +164 -0
  90. data/test/http/form_data_test.rb +232 -0
  91. data/test/http/headers/normalizer_test.rb +93 -0
  92. data/test/http/headers_test.rb +888 -0
  93. data/test/http/mime_type/json_test.rb +39 -0
  94. data/test/http/mime_type_test.rb +150 -0
  95. data/test/http/options/base_uri_test.rb +148 -0
  96. data/test/http/options/body_test.rb +21 -0
  97. data/test/http/options/features_test.rb +38 -0
  98. data/test/http/options/form_test.rb +21 -0
  99. data/test/http/options/headers_test.rb +32 -0
  100. data/test/http/options/json_test.rb +21 -0
  101. data/test/http/options/merge_test.rb +78 -0
  102. data/test/http/options/new_test.rb +37 -0
  103. data/test/http/options/proxy_test.rb +32 -0
  104. data/test/http/options_test.rb +575 -0
  105. data/test/http/redirector_test.rb +639 -0
  106. data/test/http/request/body_test.rb +318 -0
  107. data/test/http/request/builder_test.rb +623 -0
  108. data/test/http/request/writer_test.rb +391 -0
  109. data/test/http/request_test.rb +1733 -0
  110. data/test/http/response/body_test.rb +292 -0
  111. data/test/http/response/parser_test.rb +105 -0
  112. data/test/http/response/status_test.rb +322 -0
  113. data/test/http/response_test.rb +502 -0
  114. data/test/http/retriable/delay_calculator_test.rb +194 -0
  115. data/test/http/retriable/errors_test.rb +71 -0
  116. data/test/http/retriable/performer_test.rb +551 -0
  117. data/test/http/session_test.rb +424 -0
  118. data/test/http/timeout/global_test.rb +239 -0
  119. data/test/http/timeout/null_test.rb +218 -0
  120. data/test/http/timeout/per_operation_test.rb +220 -0
  121. data/test/http/uri/normalizer_test.rb +89 -0
  122. data/test/http/uri_test.rb +1140 -0
  123. data/test/http/version_test.rb +15 -0
  124. data/test/http_test.rb +818 -0
  125. data/test/regression_tests.rb +27 -0
  126. data/test/support/dummy_server/encoding_routes.rb +47 -0
  127. data/test/support/dummy_server/routes.rb +201 -0
  128. data/test/support/dummy_server/servlet.rb +81 -0
  129. data/test/support/dummy_server.rb +200 -0
  130. data/{spec → test}/support/fakeio.rb +2 -2
  131. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  132. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  133. data/test/support/http_handling_shared.rb +11 -0
  134. data/test/support/proxy_server.rb +207 -0
  135. data/test/support/servers/runner.rb +67 -0
  136. data/{spec → test}/support/simplecov.rb +11 -2
  137. data/test/support/ssl_helper.rb +108 -0
  138. data/test/test_helper.rb +38 -0
  139. metadata +108 -168
  140. data/.github/workflows/ci.yml +0 -67
  141. data/.gitignore +0 -15
  142. data/.rspec +0 -1
  143. data/.rubocop/layout.yml +0 -8
  144. data/.rubocop/metrics.yml +0 -4
  145. data/.rubocop/rspec.yml +0 -9
  146. data/.rubocop/style.yml +0 -32
  147. data/.rubocop.yml +0 -11
  148. data/.rubocop_todo.yml +0 -219
  149. data/.yardopts +0 -2
  150. data/CHANGES_OLD.md +0 -1002
  151. data/Gemfile +0 -51
  152. data/Guardfile +0 -18
  153. data/Rakefile +0 -64
  154. data/lib/http/headers/mixin.rb +0 -34
  155. data/lib/http/retriable/client.rb +0 -37
  156. data/logo.png +0 -0
  157. data/spec/lib/http/client_spec.rb +0 -556
  158. data/spec/lib/http/connection_spec.rb +0 -88
  159. data/spec/lib/http/content_type_spec.rb +0 -47
  160. data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
  161. data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
  162. data/spec/lib/http/features/instrumentation_spec.rb +0 -81
  163. data/spec/lib/http/features/logging_spec.rb +0 -65
  164. data/spec/lib/http/features/raise_error_spec.rb +0 -62
  165. data/spec/lib/http/headers/mixin_spec.rb +0 -36
  166. data/spec/lib/http/headers/normalizer_spec.rb +0 -52
  167. data/spec/lib/http/headers_spec.rb +0 -527
  168. data/spec/lib/http/options/body_spec.rb +0 -15
  169. data/spec/lib/http/options/features_spec.rb +0 -33
  170. data/spec/lib/http/options/form_spec.rb +0 -15
  171. data/spec/lib/http/options/headers_spec.rb +0 -24
  172. data/spec/lib/http/options/json_spec.rb +0 -15
  173. data/spec/lib/http/options/merge_spec.rb +0 -68
  174. data/spec/lib/http/options/new_spec.rb +0 -30
  175. data/spec/lib/http/options/proxy_spec.rb +0 -20
  176. data/spec/lib/http/options_spec.rb +0 -13
  177. data/spec/lib/http/redirector_spec.rb +0 -530
  178. data/spec/lib/http/request/body_spec.rb +0 -211
  179. data/spec/lib/http/request/writer_spec.rb +0 -121
  180. data/spec/lib/http/request_spec.rb +0 -234
  181. data/spec/lib/http/response/body_spec.rb +0 -85
  182. data/spec/lib/http/response/parser_spec.rb +0 -74
  183. data/spec/lib/http/response/status_spec.rb +0 -253
  184. data/spec/lib/http/response_spec.rb +0 -262
  185. data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
  186. data/spec/lib/http/retriable/performer_spec.rb +0 -302
  187. data/spec/lib/http/uri/normalizer_spec.rb +0 -95
  188. data/spec/lib/http/uri_spec.rb +0 -71
  189. data/spec/lib/http_spec.rb +0 -535
  190. data/spec/regression_specs.rb +0 -24
  191. data/spec/spec_helper.rb +0 -89
  192. data/spec/support/black_hole.rb +0 -13
  193. data/spec/support/dummy_server/servlet.rb +0 -203
  194. data/spec/support/dummy_server.rb +0 -44
  195. data/spec/support/fuubar.rb +0 -21
  196. data/spec/support/http_handling_shared.rb +0 -190
  197. data/spec/support/proxy_server.rb +0 -39
  198. data/spec/support/servers/config.rb +0 -11
  199. data/spec/support/servers/runner.rb +0 -19
  200. data/spec/support/ssl_helper.rb +0 -104
  201. /data/{spec → test}/support/capture_warning.rb +0 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class RegressionTest < Minitest::Test
6
+ # #248
7
+
8
+ def test_248_does_not_fail_with_github
9
+ github_uri = "http://github.com/"
10
+ HTTP.get(github_uri).to_s
11
+ end
12
+
13
+ def test_248_does_not_fail_with_googleapis
14
+ google_uri = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
15
+ HTTP.get(google_uri).to_s
16
+ end
17
+
18
+ # #422
19
+
20
+ def test_422_reads_body_when_200_ok_response_contains_upgrade_header
21
+ res = HTTP.get("https://httpbin.org/response-headers?Upgrade=h2,h2c")
22
+ parsed = res.parse(:json)
23
+
24
+ assert_includes parsed, "Upgrade"
25
+ assert_equal "h2,h2c", parsed["Upgrade"]
26
+ end
27
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DummyServer
4
+ class Servlet
5
+ post "/encoded-body" do |req, res|
6
+ res.status = 200
7
+ body = request_body(req)
8
+
9
+ res.body = case request_header(req, "Accept-Encoding")
10
+ when "gzip"
11
+ res["Content-Encoding"] = "gzip"
12
+ StringIO.open do |out|
13
+ Zlib::GzipWriter.wrap(out) do |gz|
14
+ gz.write "#{body}-gzipped"
15
+ gz.finish
16
+ out.tap(&:rewind).read
17
+ end
18
+ end
19
+ when "deflate"
20
+ res["Content-Encoding"] = "deflate"
21
+ Zlib::Deflate.deflate("#{body}-deflated")
22
+ else
23
+ "#{body}-raw"
24
+ end
25
+ end
26
+
27
+ post "/no-content-204" do |req, res|
28
+ res.status = 204
29
+ res.body = ""
30
+
31
+ case request_header(req, "Accept-Encoding")
32
+ when "gzip"
33
+ res["Content-Encoding"] = "gzip"
34
+ when "deflate"
35
+ res["Content-Encoding"] = "deflate"
36
+ end
37
+ end
38
+
39
+ get "/retry-2" do |_req, res|
40
+ @memo[:attempts] ||= 0
41
+ @memo[:attempts] += 1
42
+
43
+ res.body = "retried #{@memo[:attempts]}x"
44
+ res.status = @memo[:attempts] == 2 ? 200 : 500
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DummyServer
4
+ class Servlet
5
+ get "/" do |req, res|
6
+ res.status = 200
7
+
8
+ case req["Accept"]
9
+ when "application/json"
10
+ res["Content-Type"] = "application/json"
11
+ res.body = '{"json": true}'
12
+ else
13
+ res["Content-Type"] = "text/html"
14
+ res.body = "<!doctype html>"
15
+ end
16
+ end
17
+
18
+ get "/sleep" do |_req, res|
19
+ sleep 0.02
20
+
21
+ res.status = 200
22
+ res.body = "hello"
23
+ end
24
+
25
+ post "/sleep" do |_req, res|
26
+ sleep 0.02
27
+
28
+ res.status = 200
29
+ res.body = "hello"
30
+ end
31
+
32
+ ["", "/1", "/2"].each do |path|
33
+ get "/socket#{path}" do |req, res|
34
+ socket = req.socket
35
+ self.class.sockets << socket
36
+ res.status = 200
37
+ res.body = socket.object_id.to_s
38
+ end
39
+ end
40
+
41
+ get "/params" do |req, res|
42
+ if "foo=bar" == query_string(req)
43
+ res.status = 200
44
+ res.body = "Params!"
45
+ else
46
+ res.status = 404
47
+ res.body = "#{req.unparsed_uri} not found"
48
+ end
49
+ end
50
+
51
+ get "/multiple-params" do |req, res|
52
+ params = URI.decode_www_form(query_string(req)).group_by(&:first).transform_values { |v| v.map(&:last) }
53
+
54
+ if { "foo" => ["bar"], "baz" => ["quux"] } == params
55
+ res.status = 200
56
+ res.body = "More Params!"
57
+ else
58
+ res.status = 404
59
+ res.body = "#{req.unparsed_uri} not found"
60
+ end
61
+ end
62
+
63
+ get "/proxy" do |_req, res|
64
+ res.status = 200
65
+ res.body = "Proxy!"
66
+ end
67
+
68
+ get "/not-found" do |_req, res|
69
+ res.status = 404
70
+ res.body = "not found"
71
+ end
72
+
73
+ get "/redirect-301" do |_req, res|
74
+ res.status = 301
75
+ res["Location"] = "http://#{server_addr}:#{server_port}/"
76
+ end
77
+
78
+ get "/redirect-302" do |_req, res|
79
+ res.status = 302
80
+ res["Location"] = "http://#{server_addr}:#{server_port}/"
81
+ end
82
+
83
+ post "/form" do |req, res|
84
+ if "testing-form" == query_params(req)["example"]
85
+ res.status = 200
86
+ res.body = "passed :)"
87
+ else
88
+ res.status = 400
89
+ res.body = "invalid! >:E"
90
+ end
91
+ end
92
+
93
+ post "/body" do |req, res|
94
+ if "testing-body" == request_body(req)
95
+ res.status = 200
96
+ res.body = "passed :)"
97
+ else
98
+ res.status = 400
99
+ res.body = "invalid! >:E"
100
+ end
101
+ end
102
+
103
+ head "/" do |_req, res|
104
+ res.status = 200
105
+ res["Content-Type"] = "text/html"
106
+ end
107
+
108
+ get "/bytes" do |_req, res|
109
+ bytes = [80, 75, 3, 4, 20, 0, 0, 0, 8, 0, 123, 104, 169, 70, 99, 243, 243]
110
+ res["Content-Type"] = "application/octet-stream"
111
+ res.body = bytes.pack("c*")
112
+ end
113
+
114
+ get "/iso-8859-1" do |_req, res|
115
+ res["Content-Type"] = "text/plain; charset=ISO-8859-1"
116
+ res.body = "testæ".encode(Encoding::ISO8859_1)
117
+ end
118
+
119
+ get "/cookies" do |req, res|
120
+ cookies = request_cookies(req)
121
+ res.cookies << SetCookie.new("foo", "bar")
122
+ res.body = cookies.map { |c| [c.name, c.value].join ": " }.join("\n")
123
+ end
124
+
125
+ post "/echo-body" do |req, res|
126
+ res.status = 200
127
+ res.body = request_body(req)
128
+ end
129
+
130
+ get "/héllö-wörld".b do |_req, res|
131
+ res.status = 200
132
+ res.body = "hello world"
133
+ end
134
+
135
+ get "/echo-cookies" do |req, res|
136
+ res.status = 200
137
+ cookies = request_cookies(req)
138
+ res.body = cookies.map { |c| "#{c.name}=#{c.value}" }.join("; ")
139
+ end
140
+
141
+ get "/redirect-with-cookie" do |_req, res|
142
+ res.status = 301
143
+ res["Location"] = "http://#{server_addr}:#{server_port}/echo-cookies"
144
+ res.cookies << SetCookie.new("from_redirect", "yes", "/")
145
+ end
146
+
147
+ get "/redirect-cookie-chain/1" do |_req, res|
148
+ res.status = 301
149
+ res["Location"] = "http://#{server_addr}:#{server_port}/redirect-cookie-chain/2"
150
+ res.cookies << SetCookie.new("first", "1", "/")
151
+ end
152
+
153
+ get "/redirect-cookie-chain/2" do |_req, res|
154
+ res.status = 301
155
+ res["Location"] = "http://#{server_addr}:#{server_port}/echo-cookies"
156
+ res.cookies << SetCookie.new("second", "2", "/")
157
+ end
158
+
159
+ get "/redirect-set-then-delete/1" do |_req, res|
160
+ res.status = 301
161
+ res["Location"] = "http://#{server_addr}:#{server_port}/redirect-set-then-delete/2"
162
+ res.cookies << SetCookie.new("temp", "present", "/")
163
+ end
164
+
165
+ get "/redirect-set-then-delete/2" do |_req, res|
166
+ res.status = 301
167
+ res["Location"] = "http://#{server_addr}:#{server_port}/echo-cookies"
168
+ res.cookies << SetCookie.new("temp", "", "/")
169
+ end
170
+
171
+ get "/redirect-no-cookies" do |_req, res|
172
+ res.status = 301
173
+ res["Location"] = "http://#{server_addr}:#{server_port}/echo-cookies"
174
+ end
175
+
176
+ get "/cookie-loop" do |req, res|
177
+ cookies = request_cookies(req)
178
+ if cookies.any? { |c| c.name == "auth" && c.value == "ok" }
179
+ res.status = 200
180
+ res.body = "authenticated"
181
+ else
182
+ res.status = 302
183
+ res["Location"] = "http://#{server_addr}:#{server_port}/cookie-loop"
184
+ res.cookies << SetCookie.new("auth", "ok", "/")
185
+ end
186
+ end
187
+
188
+ get "/cross-origin-redirect" do |req, res|
189
+ target = query_params(req)["target"]
190
+ res.status = 302
191
+ res["Location"] = target
192
+ end
193
+
194
+ get "/cross-origin-redirect-with-cookie" do |req, res|
195
+ target = query_params(req)["target"]
196
+ res.status = 302
197
+ res["Location"] = target
198
+ res.cookies << SetCookie.new("from_origin", "yes", "/")
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ class DummyServer
6
+ class Servlet
7
+ def self.sockets
8
+ @sockets ||= []
9
+ end
10
+
11
+ def initialize(server, memo)
12
+ @server = server
13
+ @memo = memo
14
+ end
15
+
16
+ def service(req, res)
17
+ method = req.request_method.downcase
18
+ handler = self.class.routes["#{method}:#{req.path}"]
19
+
20
+ if handler
21
+ instance_exec(req, res, &handler)
22
+ else
23
+ res.status = 404
24
+ res.body = "#{req.unparsed_uri} not found"
25
+ end
26
+
27
+ res["Connection"] = "keep-alive"
28
+ end
29
+
30
+ class << self
31
+ def routes
32
+ @routes ||= {}
33
+ end
34
+
35
+ %w[get post head].each do |method|
36
+ define_method(method) do |path, &block|
37
+ routes["#{method}:#{path}"] = block
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def request_body(req)
45
+ req.body
46
+ end
47
+
48
+ def request_header(req, name)
49
+ req[name]
50
+ end
51
+
52
+ def request_cookies(req)
53
+ req.cookies
54
+ end
55
+
56
+ def query_string(req)
57
+ req.query_string
58
+ end
59
+
60
+ def query_params(req)
61
+ if req.body && req["Content-Type"]&.include?("application/x-www-form-urlencoded")
62
+ URI.decode_www_form(req.body).to_h
63
+ elsif req.query_string && !req.query_string.empty?
64
+ URI.decode_www_form(req.query_string).to_h
65
+ else
66
+ {}
67
+ end
68
+ end
69
+
70
+ def server_addr
71
+ @server.addr
72
+ end
73
+
74
+ def server_port
75
+ @server.port
76
+ end
77
+ end
78
+ end
79
+
80
+ require "support/dummy_server/routes"
81
+ require "support/dummy_server/encoding_routes"
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+
6
+ require "support/dummy_server/servlet"
7
+ require "support/servers/runner"
8
+ require "support/ssl_helper"
9
+
10
+ class DummyServer
11
+ def initialize(ssl: false)
12
+ @ssl = ssl
13
+ @tcp_server = TCPServer.new("127.0.0.1", 0)
14
+ @port = @tcp_server.addr[1]
15
+ @memo = {}
16
+ @servlet = Servlet.new(self, @memo)
17
+ @running = false
18
+ @ready = Queue.new
19
+ ssl_context if @ssl
20
+ end
21
+
22
+ def addr
23
+ "127.0.0.1"
24
+ end
25
+
26
+ attr_reader :port
27
+
28
+ def endpoint
29
+ "#{scheme}://#{addr}:#{port}"
30
+ end
31
+
32
+ def scheme
33
+ @ssl ? "https" : "http"
34
+ end
35
+
36
+ def wait_ready
37
+ @ready.pop
38
+ end
39
+
40
+ def start
41
+ server = @ssl ? OpenSSL::SSL::SSLServer.new(@tcp_server, ssl_context) : @tcp_server
42
+ @running = true
43
+ @ready << true
44
+
45
+ while @running
46
+ begin
47
+ client = server.accept
48
+ rescue OpenSSL::SSL::SSLError
49
+ next
50
+ end
51
+ Thread.new(client) { |c| handle_connection(c) }
52
+ end
53
+ rescue IOError, Errno::EBADF
54
+ # Server socket closed during shutdown
55
+ end
56
+
57
+ def reset
58
+ @memo.clear
59
+ Servlet.sockets.clear
60
+ end
61
+
62
+ def shutdown
63
+ @running = false
64
+ @tcp_server.close
65
+ rescue
66
+ nil
67
+ end
68
+
69
+ def ssl_context
70
+ @ssl_context ||= SSLHelper.server_context
71
+ end
72
+
73
+ # Simple HTTP request object for route handlers
74
+ Request = Struct.new(:request_method, :path, :query_string, :unparsed_uri,
75
+ :headers, :body, :socket) do
76
+ def [](name)
77
+ headers.each { |k, v| return v if k.casecmp?(name) }
78
+ nil
79
+ end
80
+
81
+ def cookies
82
+ cookie_header = self["Cookie"]
83
+ return [] unless cookie_header
84
+
85
+ cookie_header.split("; ").map do |pair|
86
+ name, value = pair.split("=", 2)
87
+ DummyServer::Cookie.new(name, value || "")
88
+ end
89
+ end
90
+ end
91
+
92
+ # Simple HTTP response object for route handlers
93
+ class Response
94
+ attr_accessor :status
95
+ attr_accessor :body
96
+ attr_accessor :cookies
97
+
98
+ def initialize
99
+ @status = 200
100
+ @body = ""
101
+ @headers = {}
102
+ @cookies = []
103
+ end
104
+
105
+ def []=(name, value)
106
+ @headers[name] = value
107
+ end
108
+
109
+ def [](name)
110
+ @headers[name]
111
+ end
112
+
113
+ def serialize(head_request: false)
114
+ lines = ["HTTP/1.1 #{status} #{STATUS_TEXT.fetch(status, 'Unknown')}"]
115
+
116
+ cookies.each do |cookie|
117
+ value = "#{cookie.name}=#{cookie.value}"
118
+ value += "; path=#{cookie.path}" if cookie.path
119
+ lines << "Set-Cookie: #{value}"
120
+ end
121
+
122
+ @headers.each { |k, v| lines << "#{k}: #{v}" }
123
+
124
+ body_bytes = body.to_s.b
125
+ lines << "Content-Length: #{body_bytes.bytesize}" unless @headers.key?("Content-Length")
126
+
127
+ header_str = lines.join("\r\n") << "\r\n\r\n"
128
+ head_request ? header_str : header_str << body_bytes
129
+ end
130
+
131
+ STATUS_TEXT = {
132
+ 200 => "OK", 204 => "No Content", 301 => "Moved Permanently",
133
+ 302 => "Found", 400 => "Bad Request", 404 => "Not Found",
134
+ 407 => "Proxy Authentication Required", 500 => "Internal Server Error"
135
+ }.freeze
136
+ end
137
+
138
+ Cookie = Struct.new(:name, :value)
139
+ SetCookie = Struct.new(:name, :value, :path)
140
+
141
+ private
142
+
143
+ def handle_connection(client)
144
+ loop do
145
+ request = read_request(client)
146
+ break unless request
147
+
148
+ response = Response.new
149
+ @servlet.service(request, response)
150
+ client.write(response.serialize(head_request: request.request_method == "HEAD"))
151
+ break unless response["Connection"]&.casecmp?("keep-alive")
152
+ end
153
+ rescue IOError, Errno::EBADF, Errno::ECONNRESET, Errno::EPIPE, Errno::EPROTOTYPE, OpenSSL::SSL::SSLError
154
+ # Connection closed or SSL error
155
+ ensure
156
+ client.close rescue nil
157
+ end
158
+
159
+ def read_request(client)
160
+ line = client.gets
161
+ return unless line
162
+
163
+ method, uri, = line.split(" ", 3)
164
+ return bad_request(client) unless uri&.ascii_only?
165
+
166
+ raw_path, query_string = uri.split("?", 2)
167
+ headers = read_headers(client)
168
+
169
+ Request.new(
170
+ request_method: method, path: percent_decode(raw_path),
171
+ query_string: query_string, headers: headers,
172
+ body: read_body(client, headers), socket: client, unparsed_uri: uri
173
+ )
174
+ end
175
+
176
+ def read_headers(client)
177
+ headers = {}
178
+ while (header_line = client.gets)
179
+ break if header_line == "\r\n"
180
+
181
+ key, value = header_line.split(": ", 2)
182
+ headers[key] = value.strip if key && value
183
+ end
184
+ headers
185
+ end
186
+
187
+ def read_body(client, headers)
188
+ content_length = headers["Content-Length"]
189
+ client.read(content_length.to_i) if content_length
190
+ end
191
+
192
+ def bad_request(client)
193
+ client.write("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
194
+ nil
195
+ end
196
+
197
+ def percent_decode(str)
198
+ str.b.gsub(/%([0-9A-Fa-f]{2})/) { [::Regexp.last_match(1)].pack("H2") }
199
+ end
200
+ end
@@ -11,8 +11,8 @@ class FakeIO
11
11
  @io.string
12
12
  end
13
13
 
14
- def read(*args)
15
- @io.read(*args)
14
+ def read(*)
15
+ @io.read(*)
16
16
  end
17
17
 
18
18
  def size
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectionReuseTests
4
+ # Including class must provide:
5
+ # - server: a DummyServer instance
6
+ # - build_client(**options): builds an HTTP::Client with given options
7
+
8
+ def test_connection_reuse_enabled_infers_host_from_persistent
9
+ client = build_client(persistent: server.endpoint)
10
+
11
+ assert_equal "<!doctype html>", client.get("/").body.to_s
12
+ end
13
+
14
+ def test_connection_reuse_enabled_reuses_the_socket
15
+ client = build_client(persistent: server.endpoint)
16
+ sockets_used = [
17
+ client.get("#{server.endpoint}/socket/1").body.to_s,
18
+ client.get("#{server.endpoint}/socket/2").body.to_s
19
+ ]
20
+
21
+ refute_includes sockets_used, ""
22
+ assert_equal 1, sockets_used.uniq.length
23
+ end
24
+
25
+ def test_connection_reuse_enabled_mixed_state_reopens_connection
26
+ client = build_client(persistent: server.endpoint)
27
+ first_socket_id = client.get("#{server.endpoint}/socket/1").body.to_s
28
+
29
+ client.instance_variable_set(:@state, :dirty)
30
+
31
+ second_socket_id = client.get("#{server.endpoint}/socket/2").body.to_s
32
+
33
+ refute_equal first_socket_id, second_socket_id
34
+ end
35
+
36
+ def test_connection_reuse_enabled_auto_flushes_unread_body
37
+ client = build_client(persistent: server.endpoint)
38
+ first_res = client.get(server.endpoint)
39
+ second_res = client.get(server.endpoint)
40
+
41
+ assert_equal "<!doctype html>", first_res.body.to_s
42
+ assert_equal "<!doctype html>", second_res.body.to_s
43
+ end
44
+
45
+ def test_connection_reuse_enabled_reading_cached_body_succeeds
46
+ client = build_client(persistent: server.endpoint)
47
+ first_res = client.get(server.endpoint)
48
+ first_res.body.to_s
49
+
50
+ second_res = client.get(server.endpoint)
51
+
52
+ assert_equal "<!doctype html>", first_res.body.to_s
53
+ assert_equal "<!doctype html>", second_res.body.to_s
54
+ end
55
+
56
+ def test_connection_reuse_enabled_socket_issue_transparently_reopens
57
+ client = build_client(persistent: server.endpoint)
58
+ first_socket_id = client.get("#{server.endpoint}/socket").body.to_s
59
+
60
+ refute_equal "", first_socket_id
61
+ # Kill off the sockets we used
62
+ DummyServer::Servlet.sockets.each do |socket|
63
+ socket.close
64
+ rescue IOError
65
+ nil
66
+ end
67
+ DummyServer::Servlet.sockets.clear
68
+
69
+ # Should error because we tried to use a bad socket
70
+ assert_raises(HTTP::ConnectionError) do
71
+ client.get("#{server.endpoint}/socket").body.to_s
72
+ end
73
+
74
+ # Should succeed since we create a new socket
75
+ second_socket_id = client.get("#{server.endpoint}/socket").body.to_s
76
+
77
+ refute_equal first_socket_id, second_socket_id
78
+ end
79
+
80
+ def test_connection_reuse_enabled_change_in_host_errors
81
+ client = build_client(persistent: server.endpoint)
82
+
83
+ err = assert_raises(HTTP::StateError) { client.get("https://invalid.com/socket") }
84
+ assert_match(/Persistence is enabled/i, err.message)
85
+ end
86
+
87
+ def test_connection_reuse_disabled_opens_new_sockets
88
+ client = build_client
89
+ sockets_used = [
90
+ client.get("#{server.endpoint}/socket/1").body.to_s,
91
+ client.get("#{server.endpoint}/socket/2").body.to_s
92
+ ]
93
+
94
+ refute_includes sockets_used, ""
95
+ assert_equal 2, sockets_used.uniq.length
96
+ end
97
+ end