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,942 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # A minimal stream that yields content then raises EOFError
6
+ class SimpleStream
7
+ def initialize(content)
8
+ @content = content
9
+ @read = false
10
+ end
11
+
12
+ def readpartial(*)
13
+ raise EOFError if @read
14
+
15
+ @read = true
16
+ @content
17
+ end
18
+ end
19
+
20
+ class HTTPFeaturesCachingTest < Minitest::Test
21
+ cover "HTTP::Features::Caching*"
22
+
23
+ def store
24
+ @store ||= HTTP::Features::Caching::InMemoryStore.new
25
+ end
26
+
27
+ def feature
28
+ @feature ||= HTTP::Features::Caching.new(store: store)
29
+ end
30
+
31
+ def request
32
+ @request ||= HTTP::Request.new(verb: :get, uri: "https://example.com/resource")
33
+ end
34
+
35
+ def post_request
36
+ @post_request ||= HTTP::Request.new(verb: :post, uri: "https://example.com/resource", body: "data")
37
+ end
38
+
39
+ def head_request
40
+ @head_request ||= HTTP::Request.new(verb: :head, uri: "https://example.com/resource")
41
+ end
42
+
43
+ def make_response(status: 200, headers: {}, body: "hello", req: request, version: "1.1",
44
+ proxy_headers: { "X-Proxy" => "true" })
45
+ HTTP::Response.new(
46
+ status: status,
47
+ version: version,
48
+ headers: headers,
49
+ proxy_headers: proxy_headers,
50
+ body: body,
51
+ request: req
52
+ )
53
+ end
54
+
55
+ def make_streaming_response(status: 200, headers: {}, content: "hello", req: request, version: "1.1")
56
+ HTTP::Response.new(
57
+ status: status,
58
+ version: version,
59
+ headers: headers,
60
+ connection: SimpleStream.new(content),
61
+ request: req
62
+ )
63
+ end
64
+
65
+ # -- #initialize --
66
+
67
+ def test_initialize_uses_in_memory_store_by_default
68
+ default_feature = HTTP::Features::Caching.new
69
+
70
+ assert_instance_of HTTP::Features::Caching::InMemoryStore, default_feature.store
71
+ end
72
+
73
+ def test_initialize_accepts_a_custom_store
74
+ assert_same store, feature.store
75
+ end
76
+
77
+ def test_initialize_is_a_feature_subclass
78
+ caching = HTTP::Features::Caching.new
79
+
80
+ assert_kind_of HTTP::Feature, caching
81
+ end
82
+
83
+ # -- #around_request --
84
+
85
+ def test_around_request_yields_original_request_for_non_get_head_requests
86
+ response = make_response(req: post_request)
87
+ yielded_request = nil
88
+ result = feature.around_request(post_request) do |req|
89
+ yielded_request = req
90
+ response
91
+ end
92
+
93
+ assert_same response, result
94
+ assert_same post_request, yielded_request
95
+ end
96
+
97
+ def test_around_request_does_not_consult_store_for_non_cacheable_methods
98
+ entry = HTTP::Features::Caching::Entry.new(
99
+ status: 200,
100
+ version: "1.1",
101
+ headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"),
102
+ proxy_headers: HTTP::Headers.coerce({}),
103
+ body: "cached",
104
+ request_uri: post_request.uri,
105
+ stored_at: Time.now
106
+ )
107
+ store.store(post_request, entry)
108
+
109
+ response = make_response(req: post_request, body: "fresh")
110
+ result = feature.around_request(post_request) { response }
111
+
112
+ assert_same response, result
113
+ end
114
+
115
+ def test_around_request_yields_original_request_when_no_cache_entry_exists
116
+ response = make_response
117
+ yielded_request = nil
118
+ result = feature.around_request(request) do |req|
119
+ yielded_request = req
120
+ response
121
+ end
122
+
123
+ assert_same response, result
124
+ assert_same request, yielded_request
125
+ end
126
+
127
+ def test_around_request_with_fresh_cached_entry_returns_cached_response_without_yielding
128
+ entry = HTTP::Features::Caching::Entry.new(
129
+ status: 200,
130
+ version: "1.1",
131
+ headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"),
132
+ proxy_headers: HTTP::Headers.coerce("X-Proxy" => "cached"),
133
+ body: "cached body",
134
+ request_uri: request.uri,
135
+ stored_at: Time.now
136
+ )
137
+ store.store(request, entry)
138
+
139
+ yielded = false
140
+ result = feature.around_request(request) { yielded = true }
141
+
142
+ refute yielded
143
+ assert_equal 200, result.status.code
144
+ assert_equal "cached body", result.body.to_s
145
+ assert_equal request.uri, result.request.uri
146
+ assert_equal "1.1", result.version
147
+ assert_equal "max-age=3600", result.headers["Cache-Control"]
148
+ end
149
+
150
+ def test_around_request_with_fresh_cached_entry_preserves_proxy_headers
151
+ entry = HTTP::Features::Caching::Entry.new(
152
+ status: 200,
153
+ version: "1.1",
154
+ headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"),
155
+ proxy_headers: HTTP::Headers.coerce("X-Proxy" => "cached-proxy"),
156
+ body: "cached body",
157
+ request_uri: request.uri,
158
+ stored_at: Time.now
159
+ )
160
+ store.store(request, entry)
161
+
162
+ result = feature.around_request(request) { raise "should not yield" }
163
+
164
+ assert_equal "cached-proxy", result.proxy_headers["X-Proxy"]
165
+ end
166
+
167
+ def test_around_request_with_stale_entry_adds_if_none_match_header_when_entry_has_etag
168
+ entry = HTTP::Features::Caching::Entry.new(
169
+ status: 200,
170
+ version: "1.1",
171
+ headers: HTTP::Headers.coerce("ETag" => '"abc123"', "Cache-Control" => "max-age=0"),
172
+ proxy_headers: HTTP::Headers.coerce({}),
173
+ body: "old body",
174
+ request_uri: request.uri,
175
+ stored_at: Time.now - 100
176
+ )
177
+ store.store(request, entry)
178
+
179
+ sent_request = nil
180
+ response = make_response(status: 200, body: "new body")
181
+ feature.around_request(request) do |req|
182
+ sent_request = req
183
+ response
184
+ end
185
+
186
+ assert_equal '"abc123"', sent_request.headers["If-None-Match"]
187
+ assert_nil request.headers["If-None-Match"]
188
+ end
189
+
190
+ def test_around_request_with_stale_entry_does_not_add_if_none_match_when_entry_has_no_etag
191
+ entry = HTTP::Features::Caching::Entry.new(
192
+ status: 200,
193
+ version: "1.1",
194
+ headers: HTTP::Headers.coerce("Last-Modified" => "Wed, 01 Jan 2025 00:00:00 GMT",
195
+ "Cache-Control" => "max-age=0"),
196
+ proxy_headers: HTTP::Headers.coerce({}),
197
+ body: "old body",
198
+ request_uri: request.uri,
199
+ stored_at: Time.now - 100
200
+ )
201
+ store.store(request, entry)
202
+
203
+ sent_request = nil
204
+ response = make_response(status: 200, body: "new body")
205
+ feature.around_request(request) do |req|
206
+ sent_request = req
207
+ response
208
+ end
209
+
210
+ assert_nil sent_request.headers["If-None-Match"]
211
+ end
212
+
213
+ def test_around_request_with_stale_entry_adds_if_modified_since_when_entry_has_last_modified
214
+ last_mod = "Wed, 01 Jan 2025 00:00:00 GMT"
215
+ entry = HTTP::Features::Caching::Entry.new(
216
+ status: 200,
217
+ version: "1.1",
218
+ headers: HTTP::Headers.coerce("Last-Modified" => last_mod, "Cache-Control" => "max-age=0"),
219
+ proxy_headers: HTTP::Headers.coerce({}),
220
+ body: "old body",
221
+ request_uri: request.uri,
222
+ stored_at: Time.now - 100
223
+ )
224
+ store.store(request, entry)
225
+
226
+ sent_request = nil
227
+ response = make_response(status: 200, body: "new body")
228
+ feature.around_request(request) do |req|
229
+ sent_request = req
230
+ response
231
+ end
232
+
233
+ assert_equal last_mod, sent_request.headers["If-Modified-Since"]
234
+ end
235
+
236
+ def test_around_request_with_stale_entry_does_not_add_if_modified_since_when_no_last_modified
237
+ entry = HTTP::Features::Caching::Entry.new(
238
+ status: 200,
239
+ version: "1.1",
240
+ headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"),
241
+ proxy_headers: HTTP::Headers.coerce({}),
242
+ body: "old body",
243
+ request_uri: request.uri,
244
+ stored_at: Time.now - 100
245
+ )
246
+ store.store(request, entry)
247
+
248
+ sent_request = nil
249
+ response = make_response(status: 200, body: "new body")
250
+ feature.around_request(request) do |req|
251
+ sent_request = req
252
+ response
253
+ end
254
+
255
+ assert_nil sent_request.headers["If-Modified-Since"]
256
+ end
257
+
258
+ def test_around_request_with_stale_entry_preserves_request_properties_in_revalidation
259
+ req_with_proxy = HTTP::Request.new(
260
+ verb: :get,
261
+ uri: "https://example.com/resource",
262
+ body: "request body",
263
+ version: "1.0",
264
+ proxy: { proxy_host: "proxy.example.com", proxy_port: 8080 }
265
+ )
266
+ entry = HTTP::Features::Caching::Entry.new(
267
+ status: 200,
268
+ version: "1.1",
269
+ headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"),
270
+ proxy_headers: HTTP::Headers.coerce({}),
271
+ body: "old body",
272
+ request_uri: req_with_proxy.uri,
273
+ stored_at: Time.now - 100
274
+ )
275
+ store.store(req_with_proxy, entry)
276
+
277
+ sent_request = nil
278
+ response = make_response(status: 200, body: "new body", req: req_with_proxy)
279
+ feature.around_request(req_with_proxy) do |req|
280
+ sent_request = req
281
+ response
282
+ end
283
+
284
+ assert_equal :get, sent_request.verb
285
+ assert_equal req_with_proxy.uri, sent_request.uri
286
+ assert_equal "1.0", sent_request.version
287
+ assert_equal "request body", sent_request.body.source
288
+ assert_equal({ proxy_host: "proxy.example.com", proxy_port: 8080 }, sent_request.proxy)
289
+ end
290
+
291
+ def test_around_request_with_stale_entry_returns_cached_response_on_304_and_updates_stored_at
292
+ old_stored_at = Time.now - 100
293
+ entry = HTTP::Features::Caching::Entry.new(
294
+ status: 200,
295
+ version: "1.1",
296
+ headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"),
297
+ proxy_headers: HTTP::Headers.coerce({}),
298
+ body: "cached body",
299
+ request_uri: request.uri,
300
+ stored_at: old_stored_at
301
+ )
302
+ store.store(request, entry)
303
+
304
+ not_modified = make_response(status: 304, body: "")
305
+ result = feature.around_request(request) { not_modified }
306
+
307
+ assert_equal 200, result.status.code
308
+ assert_equal "cached body", result.body.to_s
309
+ assert_same request, result.request
310
+ assert_operator entry.stored_at, :>, old_stored_at
311
+ end
312
+
313
+ def test_around_request_with_stale_entry_merges_304_response_headers_into_cached_entry
314
+ entry = HTTP::Features::Caching::Entry.new(
315
+ status: 200,
316
+ version: "1.1",
317
+ headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0",
318
+ "X-Old" => "preserved"),
319
+ proxy_headers: HTTP::Headers.coerce({}),
320
+ body: "cached body",
321
+ request_uri: request.uri,
322
+ stored_at: Time.now - 100
323
+ )
324
+ store.store(request, entry)
325
+
326
+ not_modified = make_response(
327
+ status: 304,
328
+ headers: { "ETag" => '"def"', "X-New" => "added" },
329
+ body: ""
330
+ )
331
+ result = feature.around_request(request) { not_modified }
332
+
333
+ assert_equal '"def"', result.headers["ETag"]
334
+ assert_equal "added", result.headers["X-New"]
335
+ assert_equal "preserved", result.headers["X-Old"]
336
+ end
337
+
338
+ def test_around_request_with_stale_entry_returns_new_response_on_non_304
339
+ entry = HTTP::Features::Caching::Entry.new(
340
+ status: 200,
341
+ version: "1.1",
342
+ headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"),
343
+ proxy_headers: HTTP::Headers.coerce({}),
344
+ body: "old body",
345
+ request_uri: request.uri,
346
+ stored_at: Time.now - 100
347
+ )
348
+ store.store(request, entry)
349
+
350
+ new_response = make_response(status: 200, body: "new body")
351
+ result = feature.around_request(request) { new_response }
352
+
353
+ assert_same new_response, result
354
+ end
355
+
356
+ def test_around_request_with_stale_entry_uses_status_predicate_to_detect_304
357
+ entry = HTTP::Features::Caching::Entry.new(
358
+ status: 200,
359
+ version: "1.1",
360
+ headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"),
361
+ proxy_headers: HTTP::Headers.coerce({}),
362
+ body: "cached body",
363
+ request_uri: request.uri,
364
+ stored_at: Time.now - 100
365
+ )
366
+ store.store(request, entry)
367
+
368
+ ok_response = make_response(status: 200, body: "new body")
369
+ result = feature.around_request(request) { ok_response }
370
+
371
+ assert_same ok_response, result
372
+ end
373
+
374
+ def test_around_request_caches_head_requests
375
+ entry = HTTP::Features::Caching::Entry.new(
376
+ status: 200,
377
+ version: "1.1",
378
+ headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"),
379
+ proxy_headers: HTTP::Headers.coerce({}),
380
+ body: "",
381
+ request_uri: head_request.uri,
382
+ stored_at: Time.now
383
+ )
384
+ store.store(head_request, entry)
385
+
386
+ yielded = false
387
+ result = feature.around_request(head_request) { yielded = true }
388
+
389
+ refute yielded
390
+ assert_equal 200, result.status.code
391
+ end
392
+
393
+ # -- #wrap_response --
394
+
395
+ def test_wrap_response_stores_cacheable_responses_and_returns_correct_properties
396
+ response = make_response(headers: { "Cache-Control" => "max-age=3600" })
397
+ result = feature.wrap_response(response)
398
+
399
+ assert store.lookup(request)
400
+ assert_equal 200, result.status.code
401
+ assert_equal "1.1", result.version
402
+ assert_equal "hello", result.body.to_s
403
+ assert_same request, result.request
404
+ end
405
+
406
+ def test_wrap_response_preserves_headers_in_stored_response
407
+ response = make_response(headers: { "Cache-Control" => "max-age=3600", "X-Custom" => "value" })
408
+ result = feature.wrap_response(response)
409
+
410
+ assert_equal "value", result.headers["X-Custom"]
411
+ end
412
+
413
+ def test_wrap_response_preserves_proxy_headers_in_stored_response
414
+ response = make_response(
415
+ headers: { "Cache-Control" => "max-age=3600" },
416
+ proxy_headers: { "X-Proxy" => "test-value" }
417
+ )
418
+ result = feature.wrap_response(response)
419
+
420
+ assert_equal "test-value", result.proxy_headers["X-Proxy"]
421
+ end
422
+
423
+ def test_wrap_response_does_not_store_responses_with_no_store
424
+ response = make_response(headers: { "Cache-Control" => "no-store" })
425
+ feature.wrap_response(response)
426
+
427
+ assert_nil store.lookup(request)
428
+ end
429
+
430
+ def test_wrap_response_does_not_store_non_cacheable_status_codes_500
431
+ response = make_response(status: 500, headers: { "Cache-Control" => "max-age=60" })
432
+ feature.wrap_response(response)
433
+
434
+ assert_nil store.lookup(request)
435
+ end
436
+
437
+ def test_wrap_response_does_not_store_400_responses
438
+ response = make_response(status: 400, headers: { "Cache-Control" => "max-age=60" })
439
+ feature.wrap_response(response)
440
+
441
+ assert_nil store.lookup(request)
442
+ end
443
+
444
+ def test_wrap_response_stores_399_responses
445
+ response = make_response(status: 399, headers: { "Cache-Control" => "max-age=60" })
446
+ feature.wrap_response(response)
447
+
448
+ assert store.lookup(request)
449
+ end
450
+
451
+ def test_wrap_response_does_not_store_1xx_responses
452
+ response = make_response(status: 100, headers: { "Cache-Control" => "max-age=60" })
453
+ feature.wrap_response(response)
454
+
455
+ assert_nil store.lookup(request)
456
+ end
457
+
458
+ def test_wrap_response_does_not_store_199_responses
459
+ response = make_response(status: 199, headers: { "Cache-Control" => "max-age=60" })
460
+ feature.wrap_response(response)
461
+
462
+ assert_nil store.lookup(request)
463
+ end
464
+
465
+ def test_wrap_response_stores_200_responses
466
+ response = make_response(status: 200, headers: { "Cache-Control" => "max-age=60" })
467
+ feature.wrap_response(response)
468
+
469
+ assert store.lookup(request)
470
+ end
471
+
472
+ def test_wrap_response_does_not_store_post_responses
473
+ response = make_response(
474
+ headers: { "Cache-Control" => "max-age=3600" },
475
+ req: post_request
476
+ )
477
+ result = feature.wrap_response(response)
478
+
479
+ assert_same response, result
480
+ assert_nil store.lookup(post_request)
481
+ end
482
+
483
+ def test_wrap_response_returns_original_response_for_non_cacheable_responses
484
+ response = make_response(headers: { "Cache-Control" => "no-store" })
485
+ result = feature.wrap_response(response)
486
+
487
+ assert_same response, result
488
+ end
489
+
490
+ def test_wrap_response_stores_response_with_etag
491
+ response = make_response(headers: { "ETag" => '"v1"' })
492
+ feature.wrap_response(response)
493
+
494
+ assert store.lookup(request)
495
+ end
496
+
497
+ def test_wrap_response_stores_response_with_last_modified
498
+ response = make_response(headers: { "Last-Modified" => "Wed, 01 Jan 2025 00:00:00 GMT" })
499
+ feature.wrap_response(response)
500
+
501
+ assert store.lookup(request)
502
+ end
503
+
504
+ def test_wrap_response_stores_response_with_expires
505
+ response = make_response(headers: { "Expires" => "Thu, 01 Jan 2099 00:00:00 GMT" })
506
+ feature.wrap_response(response)
507
+
508
+ assert store.lookup(request)
509
+ end
510
+
511
+ def test_wrap_response_does_not_store_response_without_freshness_info
512
+ response = make_response(headers: {})
513
+ feature.wrap_response(response)
514
+
515
+ assert_nil store.lookup(request)
516
+ end
517
+
518
+ def test_wrap_response_does_not_treat_non_max_age_directives_as_freshness_info
519
+ response = make_response(headers: { "Cache-Control" => "public" })
520
+ feature.wrap_response(response)
521
+
522
+ assert_nil store.lookup(request)
523
+ end
524
+
525
+ def test_wrap_response_preserves_uri_in_stored_response
526
+ response = make_response(headers: { "Cache-Control" => "max-age=3600" })
527
+ result = feature.wrap_response(response)
528
+
529
+ assert_equal request.uri, result.uri
530
+ end
531
+
532
+ def test_wrap_response_returns_a_response_with_string_body
533
+ response = make_response(headers: { "Cache-Control" => "max-age=3600" }, body: "hello")
534
+ result = feature.wrap_response(response)
535
+
536
+ assert_equal "hello", result.body.to_s
537
+ end
538
+
539
+ def test_wrap_response_eagerly_reads_streaming_body_into_a_string
540
+ response = make_streaming_response(
541
+ headers: { "Cache-Control" => "max-age=3600" },
542
+ content: "streamed content"
543
+ )
544
+ result = feature.wrap_response(response)
545
+
546
+ assert_instance_of String, result.body
547
+ assert_equal "streamed content", result.body
548
+ end
549
+
550
+ def test_wrap_response_stores_301_redirect_responses
551
+ response = make_response(
552
+ status: 301,
553
+ headers: { "Cache-Control" => "max-age=3600", "Location" => "https://example.com/new" }
554
+ )
555
+ feature.wrap_response(response)
556
+
557
+ assert store.lookup(request)
558
+ end
559
+
560
+ def test_wrap_response_stores_entry_with_correct_properties
561
+ response = make_response(
562
+ status: 200,
563
+ headers: { "Cache-Control" => "max-age=3600", "X-Custom" => "val" },
564
+ body: "stored body",
565
+ version: "1.0"
566
+ )
567
+ feature.wrap_response(response)
568
+
569
+ entry = store.lookup(request)
570
+
571
+ assert_equal 200, entry.status
572
+ assert_equal "1.0", entry.version
573
+ assert_equal "val", entry.headers["X-Custom"]
574
+ assert_equal "stored body", entry.body
575
+ assert_equal request.uri, entry.request_uri
576
+ assert_instance_of Time, entry.stored_at
577
+ end
578
+
579
+ def test_wrap_response_does_not_store_no_store_even_when_freshness_info_present
580
+ response = make_response(headers: { "Cache-Control" => "no-store, max-age=3600" })
581
+ feature.wrap_response(response)
582
+
583
+ assert_nil store.lookup(request)
584
+ end
585
+
586
+ def test_wrap_response_does_not_store_no_store_with_etag
587
+ response = make_response(headers: { "Cache-Control" => "no-store", "ETag" => '"v1"' })
588
+ feature.wrap_response(response)
589
+
590
+ assert_nil store.lookup(request)
591
+ end
592
+
593
+ def test_wrap_response_handles_uppercase_no_store_with_freshness_info
594
+ response = make_response(headers: { "Cache-Control" => "NO-STORE", "ETag" => '"v1"' })
595
+ feature.wrap_response(response)
596
+
597
+ assert_nil store.lookup(request)
598
+ end
599
+
600
+ def test_wrap_response_handles_cache_control_with_spaces_around_commas
601
+ response = make_response(headers: { "Cache-Control" => "max-age=3600 , no-store" })
602
+ feature.wrap_response(response)
603
+
604
+ assert_nil store.lookup(request)
605
+ end
606
+
607
+ def test_wrap_response_handles_no_store_with_trailing_whitespace_before_comma
608
+ response = make_response(headers: { "Cache-Control" => "no-store , max-age=3600" })
609
+ feature.wrap_response(response)
610
+
611
+ assert_nil store.lookup(request)
612
+ end
613
+
614
+ def test_wrap_response_dups_headers_in_stored_entry_to_prevent_mutation
615
+ response = make_response(headers: { "Cache-Control" => "max-age=3600", "X-Custom" => "original" })
616
+ feature.wrap_response(response)
617
+
618
+ entry = store.lookup(request)
619
+ entry.headers["X-Custom"] = "mutated"
620
+
621
+ assert_equal "original", response.headers["X-Custom"]
622
+ end
623
+
624
+ def test_wrap_response_stores_proxy_headers_in_entry
625
+ response = make_response(
626
+ headers: { "Cache-Control" => "max-age=3600" },
627
+ proxy_headers: { "X-Proxy" => "stored-proxy" }
628
+ )
629
+ feature.wrap_response(response)
630
+ entry = store.lookup(request)
631
+
632
+ assert_equal "stored-proxy", entry.proxy_headers["X-Proxy"]
633
+ end
634
+
635
+ def test_wrap_response_stores_entry_with_integer_status_code
636
+ response = make_response(status: 200, headers: { "Cache-Control" => "max-age=3600" })
637
+ feature.wrap_response(response)
638
+ entry = store.lookup(request)
639
+
640
+ assert_instance_of Integer, entry.status
641
+ end
642
+
643
+ # -- feature registration --
644
+
645
+ def test_feature_registration_is_registered_as_caching
646
+ assert_equal HTTP::Features::Caching, HTTP::Options.available_features[:caching]
647
+ end
648
+ end
649
+
650
+ class HTTPFeaturesCachingEntryTest < Minitest::Test
651
+ cover "HTTP::Features::Caching::Entry*"
652
+
653
+ def make_entry(headers: {}, stored_at: Time.now)
654
+ HTTP::Features::Caching::Entry.new(
655
+ status: 200,
656
+ version: "1.1",
657
+ headers: HTTP::Headers.coerce(headers),
658
+ proxy_headers: HTTP::Headers.coerce({}),
659
+ body: "body",
660
+ request_uri: HTTP::URI.parse("https://example.com/"),
661
+ stored_at: stored_at
662
+ )
663
+ end
664
+
665
+ # -- #fresh? --
666
+
667
+ def test_fresh_when_max_age_has_not_elapsed
668
+ entry = make_entry(headers: { "Cache-Control" => "max-age=3600" })
669
+
670
+ assert_predicate entry, :fresh?
671
+ end
672
+
673
+ def test_not_fresh_when_max_age_has_elapsed
674
+ entry = make_entry(
675
+ headers: { "Cache-Control" => "max-age=60" },
676
+ stored_at: Time.now - 120
677
+ )
678
+
679
+ refute_predicate entry, :fresh?
680
+ end
681
+
682
+ def test_fresh_when_expires_is_in_the_future
683
+ entry = make_entry(headers: { "Expires" => (Time.now + 3600).httpdate })
684
+
685
+ assert_predicate entry, :fresh?
686
+ end
687
+
688
+ def test_not_fresh_when_expires_is_in_the_past
689
+ entry = make_entry(headers: { "Expires" => (Time.now - 3600).httpdate })
690
+
691
+ refute_predicate entry, :fresh?
692
+ end
693
+
694
+ def test_not_fresh_when_no_cache_is_present
695
+ entry = make_entry(headers: { "Cache-Control" => "max-age=3600, no-cache" })
696
+
697
+ refute_predicate entry, :fresh?
698
+ end
699
+
700
+ def test_not_fresh_when_no_cache_is_present_in_uppercase
701
+ entry = make_entry(headers: { "Cache-Control" => "max-age=3600, NO-CACHE" })
702
+
703
+ refute_predicate entry, :fresh?
704
+ end
705
+
706
+ def test_not_fresh_without_any_freshness_info
707
+ entry = make_entry(headers: {})
708
+
709
+ refute_predicate entry, :fresh?
710
+ end
711
+
712
+ def test_accounts_for_age_header_in_freshness
713
+ entry = make_entry(headers: { "Cache-Control" => "max-age=100", "Age" => "90" })
714
+
715
+ assert_predicate entry, :fresh?
716
+ end
717
+
718
+ def test_not_fresh_when_age_exceeds_max_age
719
+ entry = make_entry(headers: { "Cache-Control" => "max-age=100", "Age" => "200" })
720
+
721
+ refute_predicate entry, :fresh?
722
+ end
723
+
724
+ def test_treats_age_as_float_for_precision
725
+ entry = make_entry(headers: { "Cache-Control" => "max-age=100", "Age" => "99" })
726
+
727
+ assert_predicate entry, :fresh?
728
+ end
729
+
730
+ def test_defaults_base_age_to_zero_when_no_age_header
731
+ entry = make_entry(headers: { "Cache-Control" => "max-age=1" })
732
+
733
+ assert_predicate entry, :fresh?
734
+ end
735
+
736
+ def test_handles_non_numeric_age_header_gracefully
737
+ entry = make_entry(headers: { "Cache-Control" => "max-age=3600", "Age" => "abc" })
738
+
739
+ assert_predicate entry, :fresh?
740
+ end
741
+
742
+ def test_treats_non_numeric_age_as_zero_for_freshness_calculation
743
+ entry = make_entry(
744
+ headers: { "Cache-Control" => "max-age=100", "Age" => "abc" },
745
+ stored_at: Time.now - 100.5
746
+ )
747
+
748
+ refute_predicate entry, :fresh?
749
+ end
750
+
751
+ def test_handles_invalid_expires_gracefully
752
+ entry = make_entry(headers: { "Expires" => "not-a-date" })
753
+
754
+ refute_predicate entry, :fresh?
755
+ end
756
+
757
+ def test_falls_through_to_expires_when_cache_control_has_no_max_age
758
+ entry = make_entry(headers: {
759
+ "Cache-Control" => "public",
760
+ "Expires" => (Time.now + 3600).httpdate
761
+ })
762
+
763
+ assert_predicate entry, :fresh?
764
+ end
765
+
766
+ def test_prefers_max_age_over_expires_when_both_present
767
+ entry = make_entry(
768
+ headers: { "Cache-Control" => "max-age=0", "Expires" => (Time.now + 3600).httpdate },
769
+ stored_at: Time.now - 1
770
+ )
771
+
772
+ refute_predicate entry, :fresh?
773
+ end
774
+
775
+ # -- #update_headers! --
776
+
777
+ def test_update_headers_merges_new_headers_into_entry
778
+ entry = make_entry(headers: { "ETag" => '"old"', "X-Keep" => "kept" })
779
+ new_headers = HTTP::Headers.coerce("ETag" => '"new"', "X-Added" => "added")
780
+ entry.update_headers!(new_headers)
781
+
782
+ assert_equal '"new"', entry.headers["ETag"]
783
+ assert_equal "added", entry.headers["X-Added"]
784
+ assert_equal "kept", entry.headers["X-Keep"]
785
+ end
786
+
787
+ def test_update_headers_overwrites_existing_headers_with_304_values
788
+ entry = make_entry(headers: { "Cache-Control" => "max-age=60" })
789
+ new_headers = HTTP::Headers.coerce("Cache-Control" => "max-age=120")
790
+ entry.update_headers!(new_headers)
791
+
792
+ assert_equal "max-age=120", entry.headers["Cache-Control"]
793
+ end
794
+
795
+ # -- #revalidate! --
796
+
797
+ def test_revalidate_resets_stored_at_to_current_time
798
+ old_time = Time.now - 1000
799
+ entry = make_entry(stored_at: old_time)
800
+ entry.revalidate!
801
+
802
+ assert_operator entry.stored_at, :>, old_time
803
+ end
804
+
805
+ # -- attribute readers --
806
+
807
+ def test_exposes_status
808
+ entry = make_entry
809
+
810
+ assert_equal 200, entry.status
811
+ end
812
+
813
+ def test_exposes_version
814
+ entry = make_entry
815
+
816
+ assert_equal "1.1", entry.version
817
+ end
818
+
819
+ def test_exposes_body
820
+ entry = make_entry
821
+
822
+ assert_equal "body", entry.body
823
+ end
824
+
825
+ def test_exposes_request_uri
826
+ entry = make_entry
827
+
828
+ assert_equal HTTP::URI.parse("https://example.com/"), entry.request_uri
829
+ end
830
+
831
+ def test_exposes_proxy_headers
832
+ entry = make_entry
833
+
834
+ assert_instance_of HTTP::Headers, entry.proxy_headers
835
+ end
836
+ end
837
+
838
+ class HTTPFeaturesCachingInMemoryStoreTest < Minitest::Test
839
+ cover "HTTP::Features::Caching::InMemoryStore*"
840
+
841
+ def store
842
+ @store ||= HTTP::Features::Caching::InMemoryStore.new
843
+ end
844
+
845
+ def request
846
+ @request ||= HTTP::Request.new(verb: :get, uri: "https://example.com/resource")
847
+ end
848
+
849
+ def entry
850
+ @entry ||= HTTP::Features::Caching::Entry.new(
851
+ status: 200,
852
+ version: "1.1",
853
+ headers: HTTP::Headers.coerce({}),
854
+ proxy_headers: HTTP::Headers.coerce({}),
855
+ body: "test",
856
+ request_uri: request.uri,
857
+ stored_at: Time.now
858
+ )
859
+ end
860
+
861
+ # -- #lookup --
862
+
863
+ def test_lookup_returns_nil_for_unknown_requests
864
+ assert_nil store.lookup(request)
865
+ end
866
+
867
+ def test_lookup_returns_stored_entry
868
+ store.store(request, entry)
869
+
870
+ assert_same entry, store.lookup(request)
871
+ end
872
+
873
+ # -- #store --
874
+
875
+ def test_store_stores_and_retrieves_by_request_method_and_uri
876
+ store.store(request, entry)
877
+
878
+ assert_same entry, store.lookup(request)
879
+ end
880
+
881
+ def test_store_stores_different_entries_for_different_uris
882
+ other_request = HTTP::Request.new(verb: :get, uri: "https://example.com/other")
883
+ other_entry = HTTP::Features::Caching::Entry.new(
884
+ status: 200,
885
+ version: "1.1",
886
+ headers: HTTP::Headers.coerce({}),
887
+ proxy_headers: HTTP::Headers.coerce({}),
888
+ body: "other",
889
+ request_uri: other_request.uri,
890
+ stored_at: Time.now
891
+ )
892
+
893
+ store.store(request, entry)
894
+ store.store(other_request, other_entry)
895
+
896
+ assert_same entry, store.lookup(request)
897
+ assert_same other_entry, store.lookup(other_request)
898
+ end
899
+
900
+ def test_store_stores_different_entries_for_different_verbs
901
+ head_request = HTTP::Request.new(verb: :head, uri: "https://example.com/resource")
902
+ head_entry = HTTP::Features::Caching::Entry.new(
903
+ status: 200,
904
+ version: "1.1",
905
+ headers: HTTP::Headers.coerce({}),
906
+ proxy_headers: HTTP::Headers.coerce({}),
907
+ body: "",
908
+ request_uri: head_request.uri,
909
+ stored_at: Time.now
910
+ )
911
+
912
+ store.store(request, entry)
913
+ store.store(head_request, head_entry)
914
+
915
+ assert_same entry, store.lookup(request)
916
+ assert_same head_entry, store.lookup(head_request)
917
+ end
918
+
919
+ def test_store_replaces_existing_entry
920
+ new_entry = HTTP::Features::Caching::Entry.new(
921
+ status: 200,
922
+ version: "1.1",
923
+ headers: HTTP::Headers.coerce({}),
924
+ proxy_headers: HTTP::Headers.coerce({}),
925
+ body: "updated",
926
+ request_uri: request.uri,
927
+ stored_at: Time.now
928
+ )
929
+
930
+ store.store(request, entry)
931
+ store.store(request, new_entry)
932
+
933
+ assert_same new_entry, store.lookup(request)
934
+ end
935
+
936
+ def test_store_finds_entry_using_different_request_object_with_same_verb_and_uri
937
+ store.store(request, entry)
938
+ same_request = HTTP::Request.new(verb: :get, uri: "https://example.com/resource")
939
+
940
+ assert_same entry, store.lookup(same_request)
941
+ end
942
+ end