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
@@ -1,42 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zlib"
4
3
  require "tempfile"
4
+ require "zlib"
5
5
 
6
6
  require "http/request/body"
7
7
 
8
8
  module HTTP
9
9
  module Features
10
+ # Automatically compresses request bodies with gzip or deflate
10
11
  class AutoDeflate < Feature
12
+ # Supported compression methods
13
+ VALID_METHODS = Set.new(%w[gzip deflate]).freeze
14
+
15
+ # Compression method name
16
+ #
17
+ # @example
18
+ # feature.method # => "gzip"
19
+ #
20
+ # @return [String] compression method name
21
+ # @api public
11
22
  attr_reader :method
12
23
 
13
- def initialize(**)
14
- super
24
+ # Initializes the AutoDeflate feature
25
+ #
26
+ # @example
27
+ # AutoDeflate.new(method: "gzip")
28
+ #
29
+ # @param method [String] compression method ("gzip" or "deflate")
30
+ # @return [AutoDeflate]
31
+ # @api public
32
+ def initialize(method: "gzip")
33
+ super()
15
34
 
16
- @method = @opts.key?(:method) ? @opts[:method].to_s : "gzip"
35
+ @method = String(method)
17
36
 
18
- raise Error, "Only gzip and deflate methods are supported" unless %w[gzip deflate].include?(@method)
37
+ raise Error, "Only gzip and deflate methods are supported" unless VALID_METHODS.include?(@method)
19
38
  end
20
39
 
40
+ # Wraps a request with compressed body
41
+ #
42
+ # @example
43
+ # feature.wrap_request(request)
44
+ #
45
+ # @param request [HTTP::Request]
46
+ # @return [HTTP::Request]
47
+ # @api public
21
48
  def wrap_request(request)
22
49
  return request unless method
23
- return request if request.body.size.zero?
50
+ return request if request.body.empty?
24
51
 
25
52
  # We need to delete Content-Length header. It will be set automatically by HTTP::Request::Writer
26
53
  request.headers.delete(Headers::CONTENT_LENGTH)
27
54
  request.headers[Headers::CONTENT_ENCODING] = method
28
55
 
29
- Request.new(
30
- :version => request.version,
31
- :verb => request.verb,
32
- :uri => request.uri,
33
- :headers => request.headers,
34
- :proxy => request.proxy,
35
- :body => deflated_body(request.body),
36
- :uri_normalizer => request.uri_normalizer
37
- )
56
+ build_deflated_request(request)
38
57
  end
39
58
 
59
+ # Returns a compressed body for the given body
60
+ #
61
+ # @example
62
+ # feature.deflated_body(body)
63
+ #
64
+ # @param body [HTTP::Request::Body]
65
+ # @return [GzippedBody, DeflatedBody, nil]
66
+ # @api public
40
67
  def deflated_body(body)
41
68
  case method
42
69
  when "gzip"
@@ -46,21 +73,62 @@ module HTTP
46
73
  end
47
74
  end
48
75
 
76
+ private
77
+
78
+ # Build a new request with deflated body
79
+ # @return [HTTP::Request]
80
+ # @api private
81
+ def build_deflated_request(request)
82
+ Request.new(
83
+ version: request.version,
84
+ verb: request.verb,
85
+ uri: request.uri,
86
+ headers: request.headers,
87
+ proxy: request.proxy,
88
+ body: deflated_body(request.body),
89
+ uri_normalizer: request.uri_normalizer
90
+ )
91
+ end
92
+
49
93
  HTTP::Options.register_feature(:auto_deflate, self)
50
94
 
95
+ # Base class for compressed request body wrappers
51
96
  class CompressedBody < HTTP::Request::Body
97
+ # Initializes a compressed body wrapper
98
+ #
99
+ # @example
100
+ # CompressedBody.new(uncompressed_body)
101
+ #
102
+ # @param uncompressed_body [HTTP::Request::Body]
103
+ # @return [CompressedBody]
104
+ # @api public
52
105
  def initialize(uncompressed_body)
106
+ super(nil)
53
107
  @body = uncompressed_body
54
108
  @compressed = nil
55
109
  end
56
110
 
111
+ # Returns the size of the compressed body
112
+ #
113
+ # @example
114
+ # compressed_body.size
115
+ #
116
+ # @return [Integer]
117
+ # @api public
57
118
  def size
58
119
  compress_all! unless @compressed
59
120
  @compressed.size
60
121
  end
61
122
 
123
+ # Yields each chunk of compressed data
124
+ #
125
+ # @example
126
+ # compressed_body.each { |chunk| io.write(chunk) }
127
+ #
128
+ # @return [self, Enumerator]
129
+ # @api public
62
130
  def each(&block)
63
- return to_enum __method__ unless block
131
+ return to_enum(:each) unless block
64
132
 
65
133
  if @compressed
66
134
  compressed_each(&block)
@@ -73,6 +141,9 @@ module HTTP
73
141
 
74
142
  private
75
143
 
144
+ # Yield each chunk from compressed data
145
+ # @return [void]
146
+ # @api private
76
147
  def compressed_each
77
148
  while (data = @compressed.read(Connection::BUFFER_SIZE))
78
149
  yield data
@@ -81,14 +152,25 @@ module HTTP
81
152
  @compressed.close!
82
153
  end
83
154
 
155
+ # Compress all data to a tempfile
156
+ # @return [void]
157
+ # @api private
84
158
  def compress_all!
85
- @compressed = Tempfile.new("http-compressed_body", :binmode => true)
159
+ @compressed = Tempfile.new("http-compressed_body", binmode: true)
86
160
  compress { |data| @compressed.write(data) }
87
161
  @compressed.rewind
88
162
  end
89
163
  end
90
164
 
165
+ # Gzip-compressed request body wrapper
91
166
  class GzippedBody < CompressedBody
167
+ # Compresses data using gzip
168
+ #
169
+ # @example
170
+ # gzipped_body.compress { |data| io.write(data) }
171
+ #
172
+ # @return [nil]
173
+ # @api public
92
174
  def compress(&block)
93
175
  gzip = Zlib::GzipWriter.new(BlockIO.new(block))
94
176
  @body.each { |chunk| gzip.write(chunk) }
@@ -96,18 +178,43 @@ module HTTP
96
178
  gzip.finish
97
179
  end
98
180
 
181
+ # IO adapter that delegates writes to a block
99
182
  class BlockIO
183
+ # Initializes a block-based IO adapter
184
+ #
185
+ # @example
186
+ # BlockIO.new(block)
187
+ #
188
+ # @param block [Proc]
189
+ # @return [BlockIO]
190
+ # @api public
100
191
  def initialize(block)
101
192
  @block = block
102
193
  end
103
194
 
195
+ # Writes data by calling the block
196
+ #
197
+ # @example
198
+ # block_io.write("data")
199
+ #
200
+ # @param data [String]
201
+ # @return [Object]
202
+ # @api public
104
203
  def write(data)
105
204
  @block.call(data)
106
205
  end
107
206
  end
108
207
  end
109
208
 
209
+ # Deflate-compressed request body wrapper
110
210
  class DeflatedBody < CompressedBody
211
+ # Compresses data using deflate
212
+ #
213
+ # @example
214
+ # deflated_body.compress { |data| io.write(data) }
215
+ #
216
+ # @return [nil]
217
+ # @api public
111
218
  def compress
112
219
  deflater = Zlib::Deflate.new
113
220
 
@@ -1,35 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  module HTTP
6
4
  module Features
5
+ # Automatically decompresses response bodies
7
6
  class AutoInflate < Feature
8
7
  SUPPORTED_ENCODING = Set.new(%w[deflate gzip x-gzip]).freeze
9
8
  private_constant :SUPPORTED_ENCODING
10
9
 
10
+ # Wraps a response with an auto-inflating body
11
+ #
12
+ # @example
13
+ # feature.wrap_response(response)
14
+ #
15
+ # @param response [HTTP::Response]
16
+ # @return [HTTP::Response]
17
+ # @api public
11
18
  def wrap_response(response)
12
19
  return response unless supported_encoding?(response)
13
20
 
14
- options = {
15
- :status => response.status,
16
- :version => response.version,
17
- :headers => response.headers,
18
- :proxy_headers => response.proxy_headers,
19
- :connection => response.connection,
20
- :body => stream_for(response.connection),
21
- :request => response.request
22
- }
23
-
24
- Response.new(options)
21
+ Response.new(**inflated_response_options(response)) # steep:ignore
25
22
  end
26
23
 
27
- def stream_for(connection)
28
- Response::Body.new(Response::Inflater.new(connection))
24
+ # Returns an inflating body stream for a connection
25
+ #
26
+ # @example
27
+ # feature.stream_for(connection)
28
+ #
29
+ # @param connection [HTTP::Connection]
30
+ # @param encoding [Encoding] encoding to use for the inflated body
31
+ # @return [HTTP::Response::Body]
32
+ # @api public
33
+ def stream_for(connection, encoding: Encoding::BINARY)
34
+ Response::Body.new(Response::Inflater.new(connection), encoding: encoding)
29
35
  end
30
36
 
31
37
  private
32
38
 
39
+ # Build options hash for an inflated response
40
+ # @return [Hash]
41
+ # @api private
42
+ def inflated_response_options(response)
43
+ {
44
+ status: response.status,
45
+ version: response.version,
46
+ headers: response.headers,
47
+ proxy_headers: response.proxy_headers,
48
+ connection: response.connection,
49
+ body: stream_for(response.connection, encoding: response.body.encoding),
50
+ request: response.request
51
+ }
52
+ end
53
+
54
+ # Check if the response encoding is supported
55
+ # @api private
33
56
  def supported_encoding?(response)
34
57
  content_encoding = response.headers.get(Headers::CONTENT_ENCODING).first
35
58
  content_encoding && SUPPORTED_ENCODING.include?(content_encoding)
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module HTTP
6
+ module Features
7
+ class Caching < Feature
8
+ # A cached response entry with freshness logic
9
+ class Entry
10
+ # The HTTP status code
11
+ #
12
+ # @example
13
+ # entry.status # => 200
14
+ #
15
+ # @return [Integer] the HTTP status code
16
+ # @api public
17
+ attr_reader :status
18
+
19
+ # The HTTP version
20
+ #
21
+ # @example
22
+ # entry.version # => "1.1"
23
+ #
24
+ # @return [String] the HTTP version
25
+ # @api public
26
+ attr_reader :version
27
+
28
+ # The response headers
29
+ #
30
+ # @example
31
+ # entry.headers
32
+ #
33
+ # @return [HTTP::Headers] the response headers
34
+ # @api public
35
+ attr_reader :headers
36
+
37
+ # The proxy headers from the original response
38
+ #
39
+ # @example
40
+ # entry.proxy_headers
41
+ #
42
+ # @return [HTTP::Headers] the proxy headers
43
+ # @api public
44
+ attr_reader :proxy_headers
45
+
46
+ # The response body as a string
47
+ #
48
+ # @example
49
+ # entry.body # => "<html>...</html>"
50
+ #
51
+ # @return [String] the response body
52
+ # @api public
53
+ attr_reader :body
54
+
55
+ # The URI of the original request
56
+ #
57
+ # @example
58
+ # entry.request_uri
59
+ #
60
+ # @return [HTTP::URI] the request URI
61
+ # @api public
62
+ attr_reader :request_uri
63
+
64
+ # When the response was stored
65
+ #
66
+ # @example
67
+ # entry.stored_at
68
+ #
69
+ # @return [Time] when the response was stored
70
+ # @api public
71
+ attr_reader :stored_at
72
+
73
+ # Create a new cache entry
74
+ #
75
+ # @example
76
+ # Entry.new(status: 200, version: "1.1", headers: headers,
77
+ # proxy_headers: proxy_headers, body: "hello",
78
+ # request_uri: uri, stored_at: Time.now)
79
+ #
80
+ # @param status [Integer]
81
+ # @param version [String]
82
+ # @param headers [HTTP::Headers]
83
+ # @param proxy_headers [HTTP::Headers]
84
+ # @param body [String]
85
+ # @param request_uri [HTTP::URI]
86
+ # @param stored_at [Time]
87
+ # @return [Entry]
88
+ # @api public
89
+ def initialize(status:, version:, headers:, proxy_headers:, body:, request_uri:, stored_at:)
90
+ @status = status
91
+ @version = version
92
+ @headers = headers
93
+ @proxy_headers = proxy_headers
94
+ @body = body
95
+ @request_uri = request_uri
96
+ @stored_at = stored_at
97
+ end
98
+
99
+ # Whether the cached response is still fresh
100
+ #
101
+ # @example
102
+ # entry.fresh? # => true
103
+ #
104
+ # @return [Boolean]
105
+ # @api public
106
+ def fresh?
107
+ return false if no_cache?
108
+
109
+ ttl = max_age
110
+ return age < ttl if ttl
111
+
112
+ expires = expires_at
113
+ return Time.now < expires if expires
114
+
115
+ false
116
+ end
117
+
118
+ # Reset the stored_at time to now (after successful revalidation)
119
+ #
120
+ # @example
121
+ # entry.revalidate!
122
+ #
123
+ # @return [Time]
124
+ # @api public
125
+ def revalidate!
126
+ @stored_at = Time.now
127
+ end
128
+
129
+ # Merge response headers from a 304 revalidation into the stored entry
130
+ #
131
+ # @example
132
+ # entry.update_headers!(response.headers)
133
+ #
134
+ # @param response_headers [HTTP::Headers]
135
+ # @return [void]
136
+ # @api public
137
+ def update_headers!(response_headers)
138
+ response_headers.each { |name, value| @headers[name] = value } # steep:ignore
139
+ end
140
+
141
+ private
142
+
143
+ # Age of the entry in seconds
144
+ # @return [Float]
145
+ # @api private
146
+ def age
147
+ Float(Integer(headers[Headers::AGE], exception: false) || 0) + (Time.now - stored_at)
148
+ end
149
+
150
+ # max-age value from Cache-Control, if present
151
+ # @return [Integer, nil]
152
+ # @api private
153
+ def max_age
154
+ match = String(headers[Headers::CACHE_CONTROL]).match(/max-age=(\d+)/)
155
+ return unless match
156
+
157
+ Integer(match[1])
158
+ end
159
+
160
+ # Expiration time from Expires header
161
+ # @return [Time, nil]
162
+ # @api private
163
+ def expires_at
164
+ Time.httpdate(String(headers[Headers::EXPIRES]))
165
+ rescue ArgumentError
166
+ nil
167
+ end
168
+
169
+ # Whether the Cache-Control includes no-cache
170
+ # @return [Boolean]
171
+ # @api private
172
+ def no_cache?
173
+ String(headers[Headers::CACHE_CONTROL]).downcase.include?("no-cache")
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Features
5
+ class Caching < Feature
6
+ # Simple in-memory cache store backed by a Hash
7
+ #
8
+ # Cache keys are derived from the request method and URI.
9
+ #
10
+ # @example
11
+ # store = InMemoryStore.new
12
+ # store.store(request, entry)
13
+ # store.lookup(request) # => entry
14
+ #
15
+ class InMemoryStore
16
+ # Create a new empty in-memory store
17
+ #
18
+ # @example
19
+ # store = InMemoryStore.new
20
+ #
21
+ # @return [InMemoryStore]
22
+ # @api public
23
+ def initialize
24
+ @cache = {}
25
+ end
26
+
27
+ # Look up a cached entry for a request
28
+ #
29
+ # @example
30
+ # store.lookup(request) # => Entry or nil
31
+ #
32
+ # @param request [HTTP::Request]
33
+ # @return [Entry, nil]
34
+ # @api public
35
+ def lookup(request)
36
+ @cache[cache_key(request)]
37
+ end
38
+
39
+ # Store a cache entry for a request
40
+ #
41
+ # @example
42
+ # store.store(request, entry)
43
+ #
44
+ # @param request [HTTP::Request]
45
+ # @param entry [Entry]
46
+ # @return [Entry]
47
+ # @api public
48
+ def store(request, entry)
49
+ @cache[cache_key(request)] = entry
50
+ end
51
+
52
+ private
53
+
54
+ # Compute the cache key for a request
55
+ # @return [String]
56
+ # @api private
57
+ def cache_key(request)
58
+ format("%s %s", request.verb, request.uri)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end