faraday-http-cache 2.7.0 → 3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9178a3cc4f43cad31ef2a21a3cac0ef2ccf1a3ea86e5667df90aee9aa58da4d3
4
- data.tar.gz: 28ea9a28a3fee4f78e8da8cf7af3d84f051836475ef8f6aca0e1533f0b0bafa5
3
+ metadata.gz: 8308a71aed66c20d85c6bc345b40c36f63d6362b562e2ce21ba11c002c25929a
4
+ data.tar.gz: 707c84e7c6b8d0f54f7f55142649db9623f0031197d0fe08f195ab96d9e2fe49
5
5
  SHA512:
6
- metadata.gz: 1f7d626be02155cea5f5a4ad8c26070da0a35b3e7e7c0d0af5d4441da3ccd75d46c1e697700510a0abb740e0e0ce3185ca26688f946c907d2fa0a4aab3dec3e0
7
- data.tar.gz: f2f881a67a1a7684ef8029932ffb51673c1ce23b49e09178fab248e6bb2648969d99bf1725d5878e9d33b682050577f441520297970cac22db0cc88ceb6e914f
6
+ metadata.gz: 7cfea8ce8b26348fd53e4295aed88e46f3ac5f628d6b2ee65e0c939a034c789009e9bd403c1037a8c86b4c6f7f39fa3555d4acafc27db4c4a7219abb2c335917
7
+ data.tar.gz: 037b527d2ba46be6c9f961ba417fcc585f9bda4daf21eb01fd820f4c902b597a2f32df90863dc213ed0916fa1788a142a6b106d46c2bb5afd2314fb75767b6a2
data/README.md CHANGED
@@ -62,8 +62,10 @@ you might see errors like:
62
62
  Response could not be serialized: "\xC3" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.
63
63
  ```
64
64
 
65
- For full unicode support, or if you expect to be dealing with images, you can use the stdlib
66
- [Marshal][marshal] instead. Alternatively you could use another json library like `oj` or `yajl-ruby`.
65
+ For full unicode support, or if you expect to be dealing with images, you can use another json
66
+ library like `oj` or `yajl-ruby`, or the stdlib [Marshal][marshal]. Only pick Marshal when you fully
67
+ trust the cache store: `Marshal.load` will instantiate any object found in the data, while the
68
+ default `JSON` serializer parses entries into plain hashes and never instantiates classes.
67
69
 
68
70
  ```ruby
69
71
  client = Faraday.new do |builder|
@@ -214,9 +216,11 @@ The `max-age`, `must-revalidate`, `proxy-revalidate`, `s-maxage` and
214
216
 
215
217
  ### Shared vs. non-shared caches
216
218
 
217
- By default, the middleware acts as a "shared cache" per RFC 2616. This means it does not cache
218
- responses with `Cache-Control: private`. This behavior can be changed by passing in the
219
- `:shared_cache` configuration option:
219
+ By default, the middleware acts as a "shared cache" per RFC 9111. This means it does not cache
220
+ responses with `Cache-Control: private`, and it only stores and reuses responses to requests that
221
+ carried an `Authorization` header when the response explicitly allows it with `public`,
222
+ `must-revalidate` or `s-maxage` (RFC 9111 section 3.5). This behavior can be changed by passing in
223
+ the `:shared_cache` configuration option:
220
224
 
221
225
  ```ruby
222
226
  client = Faraday.new do |builder|
@@ -34,9 +34,9 @@ module Faraday
34
34
 
35
35
  # Internal: Gets the 'max-age' directive as an Integer.
36
36
  #
37
- # Returns nil if the 'max-age' directive isn't present.
37
+ # Returns nil if the 'max-age' directive isn't present or has no value.
38
38
  def max_age
39
- @directives['max-age'].to_i if @directives.key?('max-age')
39
+ integer_directive('max-age')
40
40
  end
41
41
 
42
42
  # Internal: Gets the 'max-age' directive as an Integer.
@@ -45,16 +45,16 @@ module Faraday
45
45
  # if present to account for having to remove static age header when caching responses
46
46
  def normalize_max_ages(age)
47
47
  if age > 0
48
- @directives['max-age'] = @directives['max-age'].to_i - age if @directives.key?('max-age')
49
- @directives['s-maxage'] = @directives['s-maxage'].to_i - age if @directives.key?('s-maxage')
48
+ @directives['max-age'] = max_age - age if max_age
49
+ @directives['s-maxage'] = shared_max_age - age if shared_max_age
50
50
  end
51
51
  end
52
52
 
53
53
  # Internal: Gets the 's-maxage' directive as an Integer.
54
54
  #
55
- # Returns nil if the 's-maxage' directive isn't present.
55
+ # Returns nil if the 's-maxage' directive isn't present or has no value.
56
56
  def shared_max_age
57
- @directives['s-maxage'].to_i if @directives.key?('s-maxage')
57
+ integer_directive('s-maxage')
58
58
  end
59
59
  alias s_maxage shared_max_age
60
60
 
@@ -70,9 +70,10 @@ module Faraday
70
70
 
71
71
  # Internal: Gets the 'stale-while-revalidate' directive as an Integer.
72
72
  #
73
- # Returns nil if the 'stale-while-revalidate' directive isn't present.
73
+ # Returns nil if the 'stale-while-revalidate' directive isn't present or
74
+ # has no value.
74
75
  def stale_while_revalidate
75
- @directives['stale-while-revalidate'].to_i if @directives.key?('stale-while-revalidate')
76
+ integer_directive('stale-while-revalidate')
76
77
  end
77
78
 
78
79
  # Internal: Gets the String representation for the cache directives.
@@ -98,6 +99,16 @@ module Faraday
98
99
 
99
100
  private
100
101
 
102
+ # Internal: Reads a directive whose value must be an integer.
103
+ # A directive given without a value (a bare 'max-age') is parsed as
104
+ # true; treat it as absent instead of calling to_i on it.
105
+ #
106
+ # Returns the Integer value, or nil.
107
+ def integer_directive(name)
108
+ value = @directives[name]
109
+ value.to_i unless value.nil? || value == true
110
+ end
111
+
101
112
  # Internal: Parses the Cache Control string to a Hash.
102
113
  # Existing whitespace will be removed and the string is split on commas.
103
114
  # For each part everything before a '=' will be treated as the key
@@ -98,6 +98,22 @@ module Faraday
98
98
  cacheable?(false)
99
99
  end
100
100
 
101
+ # Internal: Checks if a shared cache may reuse this response for requests
102
+ # other than the one that carried an 'Authorization' header.
103
+ #
104
+ # RFC 9111 section 3.5: a shared cache must not use a cached response to
105
+ # a request with an 'Authorization' header to satisfy any subsequent
106
+ # request unless the response carries a 'Cache-Control' directive that
107
+ # explicitly allows it. The directives with that effect are
108
+ # 'must-revalidate', 'public' and 's-maxage'.
109
+ #
110
+ # Returns true if one of those directives is present.
111
+ def shared_cache_authorized?
112
+ cache_control.public? ||
113
+ cache_control.must_revalidate? ||
114
+ !cache_control.shared_max_age.nil?
115
+ end
116
+
101
117
  # Internal: Gets the response age in seconds.
102
118
  #
103
119
  # Returns the 'Age' header if present, or subtracts the response 'date'
@@ -29,7 +29,9 @@ module Faraday
29
29
  # @option options [Faraday::HttpCache::MemoryStore, nil] :store - a cache
30
30
  # store object that should respond to 'read', 'write', and 'delete'.
31
31
  # @option options [#dump#load] :serializer - an object that should
32
- # respond to 'dump' and 'load'.
32
+ # respond to 'dump' and 'load'. 'load' must never instantiate classes
33
+ # named by the data, since the cached entries contain response headers
34
+ # sent by the origin server.
33
35
  # @option options [Logger, nil] :logger - an object to be used to emit warnings.
34
36
  def initialize(options = {})
35
37
  @cache = options[:store] || Faraday::HttpCache::MemoryStore.new
@@ -80,7 +82,12 @@ module Faraday
80
82
  end
81
83
 
82
84
  def deserialize_object(object)
83
- @serializer.load(object).transform_keys(&:to_sym)
85
+ # JSON.load enables create_additions, so a `json_class` key in the
86
+ # entry would instantiate that class. Response headers are stored
87
+ # verbatim, which lets an origin server plant such a key. JSON.parse
88
+ # only ever builds plain Ruby objects.
89
+ loaded = @serializer.equal?(::JSON) ? ::JSON.parse(object) : @serializer.load(object)
90
+ loaded.transform_keys(&:to_sym)
84
91
  end
85
92
 
86
93
  def warn(message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Faraday
4
4
  class HttpCache
5
- VERSION = '2.7.0'
5
+ VERSION = '3.0.0'
6
6
  end
7
7
  end
@@ -196,7 +196,7 @@ module Faraday
196
196
  def process(env)
197
197
  entry = @strategy.read(@request)
198
198
 
199
- return fetch(env) if entry.nil?
199
+ return fetch(env) if entry.nil? || !reusable?(entry)
200
200
 
201
201
  if entry.fresh? && !@request.no_cache?
202
202
  response = entry.to_response(env)
@@ -270,7 +270,7 @@ module Faraday
270
270
  #
271
271
  # Returns nothing.
272
272
  def store(response)
273
- if shared_cache? ? response.cacheable_in_shared_cache? : response.cacheable_in_private_cache?
273
+ if storable?(response)
274
274
  trace :store
275
275
  @strategy.write(@request, response)
276
276
  else
@@ -278,10 +278,47 @@ module Faraday
278
278
  end
279
279
  end
280
280
 
281
+ # Internal: Checks if the response may be stored by this cache instance.
282
+ # A shared cache also refuses responses to requests that carried an
283
+ # 'Authorization' header unless the response explicitly allows it
284
+ # (RFC 9111 section 3.5), so what is never stored is never served to
285
+ # another caller.
286
+ #
287
+ # response - a 'Faraday::HttpCache::Response' instance.
288
+ #
289
+ # Returns true or false.
290
+ def storable?(response)
291
+ return response.cacheable_in_private_cache? unless shared_cache?
292
+ return false if authorization_bearing? && !response.shared_cache_authorized?
293
+
294
+ response.cacheable_in_shared_cache?
295
+ end
296
+
297
+ # Internal: Checks if a stored entry may be served for the current request.
298
+ # Entries written by earlier versions of this middleware may be responses
299
+ # to authenticated requests that a shared cache must not reuse; such an
300
+ # entry is treated as a miss and replaced.
301
+ #
302
+ # entry - a 'Faraday::HttpCache::Response' read from the strategy.
303
+ #
304
+ # Returns true or false.
305
+ def reusable?(entry)
306
+ return true unless shared_cache? && authorization_bearing?
307
+
308
+ entry.shared_cache_authorized?
309
+ end
310
+
311
+ # Internal: Checks if the current request carries an 'Authorization' header.
312
+ def authorization_bearing?
313
+ !@request.headers['Authorization'].nil?
314
+ end
315
+
281
316
  def delete(request, response)
282
- headers = %w[Location Content-Location]
283
- headers.each do |header|
284
- url = response.headers[header]
317
+ # A response cut short by a timeout or a reset can arrive without
318
+ # headers; there is still an entry to invalidate for the request URL.
319
+ headers = response.headers || {}
320
+ %w[Location Content-Location].each do |header|
321
+ url = headers[header]
285
322
  @strategy.delete(url) if url
286
323
  end
287
324
 
@@ -52,6 +52,23 @@ describe Faraday::HttpCache::CacheControl do
52
52
  expect(cache_control.shared_max_age).to eq(600)
53
53
  end
54
54
 
55
+ it 'responds to #max_age with nil when the max-age directive has no value' do
56
+ cache_control = Faraday::HttpCache::CacheControl.new('public, max-age')
57
+ expect(cache_control.max_age).to be_nil
58
+ end
59
+
60
+ it 'responds to #shared_max_age with nil when the s-maxage directive has no value' do
61
+ cache_control = Faraday::HttpCache::CacheControl.new('public, s-maxage')
62
+ expect(cache_control.shared_max_age).to be_nil
63
+ end
64
+
65
+ it 'normalizes max ages without raising when a directive has no value' do
66
+ cache_control = Faraday::HttpCache::CacheControl.new('max-age, s-maxage=600')
67
+ cache_control.normalize_max_ages(100)
68
+ expect(cache_control.max_age).to be_nil
69
+ expect(cache_control.shared_max_age).to eq(500)
70
+ end
71
+
55
72
  it 'responds to #shared_max_age with nil when no s-maxage directive present' do
56
73
  cache_control = Faraday::HttpCache::CacheControl.new('public')
57
74
  expect(cache_control.shared_max_age).to be_nil
@@ -116,4 +133,14 @@ describe Faraday::HttpCache::CacheControl do
116
133
  cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60')
117
134
  expect(cache_control.stale_while_revalidate).to be_nil
118
135
  end
136
+
137
+ it 'responds to #stale_while_revalidate with 0 when directive is invalid true string value' do
138
+ cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60, stale-while-revalidate=true')
139
+ expect(cache_control.stale_while_revalidate).to eq(0)
140
+ end
141
+
142
+ it 'responds to #stale_while_revalidate with nil when directive has no integer assignment' do
143
+ cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60, stale-while-revalidate')
144
+ expect(cache_control.stale_while_revalidate).to be_nil
145
+ end
119
146
  end
@@ -96,6 +96,33 @@ describe Faraday::HttpCache do
96
96
  client.get('broken')
97
97
  end
98
98
 
99
+ it 'still expires the request URL when the response has no headers' do
100
+ store = Faraday::HttpCache::MemoryStore.new
101
+ cached = Faraday.new(url: ENV['FARADAY_SERVER']) do |stack|
102
+ stack.use Faraday::HttpCache, store: store
103
+ stack.adapter ENV['FARADAY_ADAPTER'].to_sym
104
+ end
105
+ # Mimics an adapter that gives up mid-response: the env is completed
106
+ # with no status worth the name and no response headers at all.
107
+ headerless_adapter = Class.new(Faraday::Adapter) do
108
+ def call(env)
109
+ super
110
+ env.status = 0
111
+ env.response_headers = nil
112
+ env.response.finish(env)
113
+ end
114
+ end
115
+ broken = Faraday.new(url: ENV['FARADAY_SERVER']) do |stack|
116
+ stack.use Faraday::HttpCache, store: store
117
+ stack.adapter headerless_adapter
118
+ end
119
+
120
+ cached.get('get')
121
+ broken.post('get')
122
+
123
+ expect(cached.get('get').body).to eq('2')
124
+ end
125
+
99
126
  it 'expires entries for the "Location" header' do
100
127
  client.get('get')
101
128
  client.post('delete-with-location')
@@ -121,6 +148,48 @@ describe Faraday::HttpCache do
121
148
  expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /private] miss, uncacheable') }
122
149
  client.get('private')
123
150
  end
151
+
152
+ describe 'responses to requests with an "Authorization" header' do
153
+ def get_as(user, path = 'authenticated')
154
+ client.get(path) { |request| request.headers['Authorization'] = "Bearer #{user}" }
155
+ end
156
+
157
+ it 'does not serve one caller the response cached for another' do
158
+ alice = get_as('alice')
159
+ bob = get_as('bob')
160
+
161
+ expect(alice.body).to eq('1:Bearer alice')
162
+ expect(bob.body).to eq('2:Bearer bob')
163
+ end
164
+
165
+ it 'logs that the response is uncacheable' do
166
+ expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /authenticated] miss, uncacheable') }
167
+ get_as('alice')
168
+ end
169
+
170
+ it 'caches responses that are explicitly marked as public' do
171
+ get_as('alice', 'authenticated-public')
172
+ bob = get_as('bob', 'authenticated-public')
173
+
174
+ expect(bob.body).to eq('1:Bearer alice')
175
+ end
176
+
177
+ it 'does not serve entries stored before the authorization check existed' do
178
+ store = Faraday::HttpCache::MemoryStore.new
179
+ clients = [false, true].map do |shared|
180
+ Faraday.new(url: ENV['FARADAY_SERVER']) do |stack|
181
+ stack.use Faraday::HttpCache, store: store, shared_cache: shared
182
+ stack.adapter ENV['FARADAY_ADAPTER'].to_sym
183
+ end
184
+ end
185
+ private_client, shared_client = clients
186
+
187
+ private_client.get('authenticated') { |request| request.headers['Authorization'] = 'Bearer alice' }
188
+ bob = shared_client.get('authenticated') { |request| request.headers['Authorization'] = 'Bearer bob' }
189
+
190
+ expect(bob.body).to eq('2:Bearer bob')
191
+ end
192
+ end
124
193
  end
125
194
 
126
195
  describe 'when acting as a private cache' do
@@ -135,6 +204,13 @@ describe Faraday::HttpCache do
135
204
  expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /private] miss, store') }
136
205
  client.get('private')
137
206
  end
207
+
208
+ it 'caches responses to requests with an "Authorization" header' do
209
+ client.get('authenticated') { |request| request.headers['Authorization'] = 'Bearer alice' }
210
+ bob = client.get('authenticated') { |request| request.headers['Authorization'] = 'Bearer bob' }
211
+
212
+ expect(bob.body).to eq('1:Bearer alice')
213
+ end
138
214
  end
139
215
 
140
216
  it 'does not cache responses with a explicit no-store directive' do
data/spec/spec_helper.rb CHANGED
@@ -16,6 +16,7 @@ require 'active_support/cache'
16
16
 
17
17
  require 'support/test_app'
18
18
  require 'support/test_server'
19
+ require 'support/json_gadget'
19
20
 
20
21
  server = TestServer.new
21
22
 
@@ -16,6 +16,20 @@ describe Faraday::HttpCache::Strategies::ByUrl do
16
16
  let(:strategy) { described_class.new(store: cache) }
17
17
  subject { strategy }
18
18
 
19
+ describe 'deserializing entries' do
20
+ let(:response) { double(serializable_hash: { response_headers: { 'json_class' => 'JsonGadget' } }) }
21
+
22
+ before { JsonGadget.invocations.clear }
23
+
24
+ it 'never instantiates classes named by the cached data' do
25
+ strategy.write(request, response)
26
+ cached = strategy.read(request)
27
+
28
+ expect(JsonGadget.invocations).to be_empty
29
+ expect(cached.payload[:response_headers]['json_class']).to eq('JsonGadget')
30
+ end
31
+ end
32
+
19
33
  describe 'Cache configuration' do
20
34
  it 'uses a MemoryStore by default' do
21
35
  expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original
@@ -23,6 +23,20 @@ describe Faraday::HttpCache::Strategies::ByVary do
23
23
  let(:strategy) { described_class.new(store: cache) }
24
24
  subject { strategy }
25
25
 
26
+ describe 'deserializing entries' do
27
+ let(:response_payload) { { response_headers: { 'Vary' => vary, 'json_class' => 'JsonGadget' } } }
28
+
29
+ before { JsonGadget.invocations.clear }
30
+
31
+ it 'never instantiates classes named by the cached data' do
32
+ strategy.write(request, response)
33
+ cached = strategy.read(request)
34
+
35
+ expect(JsonGadget.invocations).to be_empty
36
+ expect(cached.payload[:response_headers]['json_class']).to eq('JsonGadget')
37
+ end
38
+ end
39
+
26
40
  describe 'storing responses' do
27
41
  shared_examples 'A strategy with serialization' do
28
42
  it 'writes the response object to the underlying cache' do
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that records every attempt to build it through JSON.load's
4
+ # create_additions hook, so specs can assert cached entries never do that.
5
+ class JsonGadget
6
+ def self.invocations
7
+ @invocations ||= []
8
+ end
9
+
10
+ def self.json_create(attributes)
11
+ invocations << attributes
12
+ new
13
+ end
14
+ end
@@ -85,6 +85,14 @@ class TestApp < Sinatra::Base
85
85
  halt 405
86
86
  end
87
87
 
88
+ get '/authenticated' do
89
+ [200, { 'Cache-Control' => 'max-age=200' }, "#{increment_counter}:#{env['HTTP_AUTHORIZATION']}"]
90
+ end
91
+
92
+ get '/authenticated-public' do
93
+ [200, { 'Cache-Control' => 'public, max-age=200' }, "#{increment_counter}:#{env['HTTP_AUTHORIZATION']}"]
94
+ end
95
+
88
96
  get '/private' do
89
97
  [200, { 'Cache-Control' => 'private, max-age=100' }, increment_counter]
90
98
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faraday-http-cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Mazza
@@ -59,6 +59,7 @@ files:
59
59
  - spec/strategies/by_url_spec.rb
60
60
  - spec/strategies/by_vary_spec.rb
61
61
  - spec/support/empty.png
62
+ - spec/support/json_gadget.rb
62
63
  - spec/support/test_app.rb
63
64
  - spec/support/test_server.rb
64
65
  - spec/validation_spec.rb
@@ -73,14 +74,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
73
74
  requirements:
74
75
  - - ">="
75
76
  - !ruby/object:Gem::Version
76
- version: 3.2.0
77
+ version: 3.3.0
77
78
  required_rubygems_version: !ruby/object:Gem::Requirement
78
79
  requirements:
79
80
  - - ">="
80
81
  - !ruby/object:Gem::Version
81
82
  version: '0'
82
83
  requirements: []
83
- rubygems_version: 4.0.3
84
+ rubygems_version: 3.6.9
84
85
  specification_version: 4
85
86
  summary: A Faraday middleware that stores and validates cache expiration.
86
87
  test_files:
@@ -97,6 +98,7 @@ test_files:
97
98
  - spec/strategies/by_url_spec.rb
98
99
  - spec/strategies/by_vary_spec.rb
99
100
  - spec/support/empty.png
101
+ - spec/support/json_gadget.rb
100
102
  - spec/support/test_app.rb
101
103
  - spec/support/test_server.rb
102
104
  - spec/validation_spec.rb