faraday-http-cache 2.3.0 → 2.4.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: 962b23db73b9799330e7229f9a6d6e252705a55f3c3ad316f810132160a30522
4
- data.tar.gz: 51a43a544f924e75c00ffffd777368cf995000a3855e1ea7b9b86fb54b5f6b2a
3
+ metadata.gz: 6603be3bbf6a2840ba11f947f7e42f030ab52600912e9a62d16aaf27ee5e6446
4
+ data.tar.gz: 95b8b2f4b08525406027d47e14341f5f301409a2518332b243dc08d2bde4e693
5
5
  SHA512:
6
- metadata.gz: 57de8b27682d5aa8367fd7612ec3814c300a0856ba7e1ee61354646117a2370f216981b60f5224662236444a44669a70840fb07118602a82b66a0c886ae1540d
7
- data.tar.gz: d8e1644ed674c3c7a6c8f44adfe83eda6f9268590235330eaf0ed62ec41795a97a35f28f52bddf7c3103a8cef6c849330167b1b708a6c8124da642e371bf434b
6
+ metadata.gz: 4675c865c18641322c71b77881a74d3ca4edbf4fb1d628ac0fe7a6388d6a3c973946f1d7fa7b09d654f1eff61484a8c2d8524c966e4377c1e12854bf8114e13a
7
+ data.tar.gz: 832d7579eb75d3631e539ca7af996b9d062dbe43582468f98c349b3dbc963dde17cecf57a0202af1916297013143becba1a0fc1f32ddf92ab98490876e3efc84
data/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Faraday Http Cache
2
2
 
3
- [![Build Status](https://secure.travis-ci.org/sourcelevel/faraday-http-cache.svg?branch=master)](https://travis-ci.org/sourcelevel/faraday-http-cache)
3
+ [![Gem Version](https://badge.fury.io/rb/faraday-http-cache.svg)](https://rubygems.org/gems/faraday-http-cache)
4
+ [![Build](https://github.com/sourcelevel/faraday-http-cache/actions/workflows/main.yml/badge.svg)](https://github.com/sourcelevel/faraday-http-cache/actions)
4
5
 
5
- a [Faraday](https://github.com/lostisland/faraday) middleware that respects HTTP cache,
6
+ A [Faraday](https://github.com/lostisland/faraday) middleware that respects HTTP cache,
6
7
  by checking expiration and validation of the stored responses.
7
8
 
8
9
  ## Installation
@@ -53,15 +54,15 @@ This type of store **might not be persisted across multiple processes or connect
53
54
  so it is probably not suitable for most production environments.
54
55
  Make sure that you configure a store that is suitable for you.
55
56
 
56
- The stdlib `JSON` module is used for serialization by default, which can struggle with unicode
57
- characters in responses. For example, if your JSON returns `"name": "Raül"` then you might see
58
- errors like:
57
+ The stdlib `JSON` module is used for serialization by default, which can struggle with unicode
58
+ characters in responses in Ruby < 3.1. For example, if your JSON returns `"name": "Raül"` then
59
+ you might see errors like:
59
60
 
60
61
  ```
61
62
  Response could not be serialized: "\xC3" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.
62
63
  ```
63
64
 
64
- For full unicode support, or if you expect to be dealing with images, you can use
65
+ For full unicode support, or if you expect to be dealing with images, you can use the stdlib
65
66
  [Marshal][marshal] instead. Alternatively you could use another json library like `oj` or `yajl-ruby`.
66
67
 
67
68
  ```ruby
@@ -71,9 +72,47 @@ client = Faraday.new do |builder|
71
72
  end
72
73
  ```
73
74
 
75
+ ### Strategies
76
+
77
+ You can provide a `:strategy` option to the middleware to specify the strategy to use.
78
+
79
+ ```ruby
80
+ client = Faraday.new do |builder|
81
+ builder.use :http_cache, store: Rails.cache, strategy: Faraday::HttpCache::Strategies::ByVary
82
+ builder.adapter Faraday.default_adapter
83
+ end
84
+ ```
85
+
86
+ Available strategies are:
87
+
88
+ #### `Faraday::HttpCache::Strategies::ByUrl`
89
+
90
+ The default strategy.
91
+ It Uses URL + HTTP method to generate cache keys and stores an array of request + response for each key.
92
+
93
+ #### `Faraday::HttpCache::Strategies::ByVary`
94
+
95
+ This strategy uses headers from `Vary` header to generate cache keys.
96
+ It also uses cache to store `Vary` headers mapped to the request URL.
97
+ This strategy is more suitable for caching private responses with the same URLs but different results for different users, like `https://api.github.com/user`.
98
+
99
+ *Note:* To automatically remove stale cache keys, you might want to use the `:expires_in` option.
100
+
101
+ ```ruby
102
+ store = ActiveSupport::Cache.lookup_store(:redis_cache_store, expires_in: 1.day, url: 'redis://localhost:6379/0')
103
+ client = Faraday.new do |builder|
104
+ builder.use :http_cache, store: store, strategy: Faraday::HttpCache::Strategies::ByVary
105
+ builder.adapter Faraday.default_adapter
106
+ end
107
+ ```
108
+
109
+ #### Custom strategies
110
+
111
+ You can write your own strategy by subclassing `Faraday::HttpCache::Strategies::BaseStrategy` and implementing `#write`, `#read` and `#delete` methods.
112
+
74
113
  ### Logging
75
114
 
76
- You can provide a `:logger` option that will be receive debug informations based on the middleware
115
+ You can provide a `:logger` option that will receive debug information based on the middleware
77
116
  operations:
78
117
 
79
118
  ```ruby
@@ -82,7 +121,7 @@ client = Faraday.new do |builder|
82
121
  builder.adapter Faraday.default_adapter
83
122
  end
84
123
 
85
- client.get('http://site/api/users')
124
+ client.get('https://site/api/users')
86
125
  # logs "HTTP Cache: [GET users] miss, store"
87
126
  ```
88
127
 
@@ -133,6 +172,7 @@ execute the files under the `examples` directory to see a sample of the middlewa
133
172
  ## What gets cached?
134
173
 
135
174
  The middleware will use the following headers to make caching decisions:
175
+ - Vary
136
176
  - Cache-Control
137
177
  - Age
138
178
  - Last-Modified
@@ -155,7 +195,7 @@ client = Faraday.new do |builder|
155
195
  builder.adapter Faraday.default_adapter
156
196
  end
157
197
 
158
- client.get('http://site/api/some-private-resource') # => will be cached
198
+ client.get('https://site/api/some-private-resource') # => will be cached
159
199
  ```
160
200
 
161
201
  ## License
@@ -163,4 +203,4 @@ client.get('http://site/api/some-private-resource') # => will be cached
163
203
  Copyright (c) 2012-2018 Plataformatec.
164
204
  Copyright (c) 2019 SourceLevel and contributors.
165
205
 
166
- [marshal]: http://www.ruby-doc.org/core-2.0/Marshal.html
206
+ [marshal]: https://www.ruby-doc.org/core-3.0/Marshal.html
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faraday
4
+ class HttpCache < Faraday::Middleware
5
+ # @private
6
+ # A Hash based store to be used by strategies
7
+ # when a `store` is not provided for the middleware setup.
8
+ class MemoryStore
9
+ def initialize
10
+ @cache = {}
11
+ end
12
+
13
+ def read(key)
14
+ @cache[key]
15
+ end
16
+
17
+ def delete(key)
18
+ @cache.delete(key)
19
+ end
20
+
21
+ def write(key, value)
22
+ @cache[key] = value
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,194 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'digest/sha1'
3
+ require 'faraday/http_cache/strategies/by_url'
5
4
 
6
5
  module Faraday
7
6
  class HttpCache < Faraday::Middleware
8
- # Internal: A wrapper around an ActiveSupport::CacheStore to store responses.
9
- #
10
- # Examples
11
- #
12
- # # Creates a new Storage using a MemCached backend from ActiveSupport.
13
- # mem_cache_store = ActiveSupport::Cache.lookup_store(:mem_cache_store, ['localhost:11211'])
14
- # Faraday::HttpCache::Storage.new(store: mem_cache_store)
15
- #
16
- # # Reuse some other instance of an ActiveSupport::Cache::Store object.
17
- # Faraday::HttpCache::Storage.new(store: Rails.cache)
18
- #
19
- # # Creates a new Storage using Marshal for serialization.
20
- # Faraday::HttpCache::Storage.new(store: Rails.cache, serializer: Marshal)
21
- class Storage
22
- # Public: Gets the underlying cache store object.
23
- attr_reader :cache
24
-
25
- # Internal: Initialize a new Storage object with a cache backend.
26
- #
27
- # :logger - A Logger object to be used to emit warnings.
28
- # :store - An cache store object that should respond to 'read',
29
- # 'write', and 'delete'.
30
- # :serializer - A serializer object that should respond to 'dump'
31
- # and 'load'.
32
- def initialize(store: nil, serializer: nil, logger: nil)
33
- @cache = store || MemoryStore.new
34
- @serializer = serializer || JSON
35
- @logger = logger
36
- assert_valid_store!
37
- end
38
-
39
- # Internal: Store a response inside the cache.
40
- #
41
- # request - A Faraday::HttpCache::::Request instance of the executed HTTP
42
- # request.
43
- # response - The Faraday::HttpCache::Response instance to be stored.
44
- #
45
- # Returns nothing.
46
- def write(request, response)
47
- key = cache_key_for(request.url)
48
- entry = serialize_entry(request.serializable_hash, response.serializable_hash)
49
-
50
- entries = cache.read(key) || []
51
- entries = entries.dup if entries.frozen?
52
-
53
- entries.reject! do |(cached_request, cached_response)|
54
- response_matches?(request, deserialize_object(cached_request), deserialize_object(cached_response))
55
- end
56
-
57
- entries << entry
58
-
59
- cache.write(key, entries)
60
- rescue ::Encoding::UndefinedConversionError => e
61
- warn "Response could not be serialized: #{e.message}. Try using Marshal to serialize."
62
- raise e
63
- end
64
-
65
- # Internal: Attempt to retrieve an stored response that suits the incoming
66
- # HTTP request.
67
- #
68
- # request - A Faraday::HttpCache::::Request instance of the incoming HTTP
69
- # request.
70
- # klass - The Class to be instantiated with the stored response.
71
- #
72
- # Returns an instance of 'klass'.
73
- def read(request, klass: Faraday::HttpCache::Response)
74
- cache_key = cache_key_for(request.url)
75
- entries = cache.read(cache_key)
76
- response = lookup_response(request, entries)
77
-
78
- if response
79
- klass.new(response)
80
- end
81
- end
82
-
83
- def delete(url)
84
- cache_key = cache_key_for(url)
85
- cache.delete(cache_key)
86
- end
87
-
88
- private
89
-
90
- # Internal: Retrieve a response Hash from the list of entries that match
91
- # the given request.
92
- #
93
- # request - A Faraday::HttpCache::::Request instance of the incoming HTTP
94
- # request.
95
- # entries - An Array of pairs of Hashes (request, response).
96
- #
97
- # Returns a Hash or nil.
98
- def lookup_response(request, entries)
99
- if entries
100
- entries = entries.map { |entry| deserialize_entry(*entry) }
101
- _, response = entries.find { |req, res| response_matches?(request, req, res) }
102
- response
103
- end
104
- end
105
-
106
- # Internal: Check if a cached response and request matches the given
107
- # request.
108
- #
109
- # request - A Faraday::HttpCache::::Request instance of the
110
- # current HTTP request.
111
- # cached_request - The Hash of the request that was cached.
112
- # cached_response - The Hash of the response that was cached.
113
- #
114
- # Returns true or false.
115
- def response_matches?(request, cached_request, cached_response)
116
- request.method.to_s == cached_request[:method].to_s &&
117
- vary_matches?(cached_response, request, cached_request)
118
- end
119
-
120
- def vary_matches?(cached_response, request, cached_request)
121
- headers = Faraday::Utils::Headers.new(cached_response[:response_headers])
122
- vary = headers['Vary'].to_s
123
-
124
- vary.empty? || (vary != '*' && vary.split(/[\s,]+/).all? do |header|
125
- request.headers[header] == cached_request[:headers][header]
126
- end)
127
- end
128
-
129
- def serialize_entry(*objects)
130
- objects.map { |object| serialize_object(object) }
131
- end
132
-
133
- def serialize_object(object)
134
- @serializer.dump(object)
135
- end
136
-
137
- def deserialize_entry(*objects)
138
- objects.map { |object| deserialize_object(object) }
139
- end
140
-
141
- def deserialize_object(object)
142
- @serializer.load(object).each_with_object({}) do |(key, value), hash|
143
- hash[key.to_sym] = value
144
- end
145
- end
146
-
147
- # Internal: Computes the cache key for a specific request, taking in
148
- # account the current serializer to avoid cross serialization issues.
149
- #
150
- # url - The request URL.
151
- #
152
- # Returns a String.
153
- def cache_key_for(url)
154
- prefix = (@serializer.is_a?(Module) ? @serializer : @serializer.class).name
155
- Digest::SHA1.hexdigest("#{prefix}#{url}")
156
- end
157
-
158
- # Internal: Checks if the given cache object supports the
159
- # expect API ('read' and 'write').
160
- #
161
- # Raises an 'ArgumentError'.
162
- #
163
- # Returns nothing.
164
- def assert_valid_store!
165
- unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:delete)
166
- raise ArgumentError.new("#{cache.inspect} is not a valid cache store as it does not responds to 'read', 'write' or 'delete'.")
167
- end
168
- end
169
-
170
- def warn(message)
171
- @logger&.warn(message)
172
- end
173
- end
174
-
175
- # Internal: A Hash based store to be used by the 'Storage' class
176
- # when a 'store' is not provided for the middleware setup.
177
- class MemoryStore
178
- def initialize
179
- @cache = {}
180
- end
181
-
182
- def read(key)
183
- @cache[key]
184
- end
185
-
186
- def delete(key)
187
- @cache.delete(key)
188
- end
189
-
190
- def write(key, value)
191
- @cache[key] = value
7
+ # @deprecated Use Faraday::HttpCache::Strategies::ByUrl instead.
8
+ class Storage < Faraday::HttpCache::Strategies::ByUrl
9
+ def initialize(*)
10
+ Kernel.warn("Deprecated: #{self.class} is deprecated and will be removed in " \
11
+ 'the next major release. Use Faraday::HttpCache::Strategies::ByUrl instead.')
12
+ super
192
13
  end
193
14
  end
194
15
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'faraday/http_cache/memory_store'
5
+
6
+ module Faraday
7
+ class HttpCache < Faraday::Middleware
8
+ module Strategies
9
+ # Base class for all strategies.
10
+ # @abstract
11
+ #
12
+ # @example
13
+ #
14
+ # # Creates a new strategy using a MemCached backend from ActiveSupport.
15
+ # mem_cache_store = ActiveSupport::Cache.lookup_store(:mem_cache_store, ['localhost:11211'])
16
+ # Faraday::HttpCache::Strategies::ByVary.new(store: mem_cache_store)
17
+ #
18
+ # # Reuse some other instance of an ActiveSupport::Cache::Store object.
19
+ # Faraday::HttpCache::Strategies::ByVary.new(store: Rails.cache)
20
+ #
21
+ # # Creates a new strategy using Marshal for serialization.
22
+ # Faraday::HttpCache::Strategies::ByVary.new(store: Rails.cache, serializer: Marshal)
23
+ class BaseStrategy
24
+ # Returns the underlying cache store object.
25
+ attr_reader :cache
26
+
27
+ # @param [Hash] options the options to create a message with.
28
+ # @option options [Faraday::HttpCache::MemoryStore, nil] :store - a cache
29
+ # store object that should respond to 'read', 'write', and 'delete'.
30
+ # @option options [#dump#load] :serializer - an object that should
31
+ # respond to 'dump' and 'load'.
32
+ # @option options [Logger, nil] :logger - an object to be used to emit warnings.
33
+ def initialize(options = {})
34
+ @cache = options[:store] || Faraday::HttpCache::MemoryStore.new
35
+ @serializer = options[:serializer] || JSON
36
+ @logger = options[:logger] || Logger.new(IO::NULL)
37
+ @cache_salt = (@serializer.is_a?(Module) ? @serializer : @serializer.class).name
38
+ assert_valid_store!
39
+ end
40
+
41
+ # Store a response inside the cache.
42
+ # @abstract
43
+ def write(_request, _response)
44
+ raise NotImplementedError, 'Implement this method in your strategy'
45
+ end
46
+
47
+ # Read a response from the cache.
48
+ # @abstract
49
+ def read(_request)
50
+ raise NotImplementedError, 'Implement this method in your strategy'
51
+ end
52
+
53
+ # Delete responses from the cache by the url.
54
+ # @abstract
55
+ def delete(_url)
56
+ raise NotImplementedError, 'Implement this method in your strategy'
57
+ end
58
+
59
+ private
60
+
61
+ # @private
62
+ # @raise [ArgumentError] if the cache object doesn't support the expect API.
63
+ def assert_valid_store!
64
+ unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:delete)
65
+ raise ArgumentError.new("#{cache.inspect} is not a valid cache store as it does not responds to 'read', 'write' or 'delete'.")
66
+ end
67
+ end
68
+
69
+ def serialize_entry(*objects)
70
+ objects.map { |object| serialize_object(object) }
71
+ end
72
+
73
+ def serialize_object(object)
74
+ @serializer.dump(object)
75
+ end
76
+
77
+ def deserialize_entry(*objects)
78
+ objects.map { |object| deserialize_object(object) }
79
+ end
80
+
81
+ def deserialize_object(object)
82
+ @serializer.load(object).each_with_object({}) do |(key, value), hash|
83
+ hash[key.to_sym] = value
84
+ end
85
+ end
86
+
87
+ def warn(message)
88
+ @logger.warn(message)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ require 'faraday/http_cache/strategies/base_strategy'
6
+
7
+ module Faraday
8
+ class HttpCache < Faraday::Middleware
9
+ module Strategies
10
+ # The original strategy by Faraday::HttpCache.
11
+ # Uses URL + HTTP method to generate cache keys.
12
+ class ByUrl < BaseStrategy
13
+ # Store a response inside the cache.
14
+ #
15
+ # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request.
16
+ # @param [Faraday::HttpCache::Response] response - instance to be stored.
17
+ #
18
+ # @return [void]
19
+ def write(request, response)
20
+ key = cache_key_for(request.url)
21
+ entry = serialize_entry(request.serializable_hash, response.serializable_hash)
22
+ entries = cache.read(key) || []
23
+ entries = entries.dup if entries.frozen?
24
+ entries.reject! do |(cached_request, cached_response)|
25
+ response_matches?(request, deserialize_object(cached_request), deserialize_object(cached_response))
26
+ end
27
+
28
+ entries << entry
29
+
30
+ cache.write(key, entries)
31
+ rescue ::Encoding::UndefinedConversionError => e
32
+ warn "Response could not be serialized: #{e.message}. Try using Marshal to serialize."
33
+ raise e
34
+ end
35
+
36
+ # Fetch a stored response that suits the incoming HTTP request or return nil.
37
+ #
38
+ # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request.
39
+ #
40
+ # @return [Faraday::HttpCache::Response, nil]
41
+ def read(request)
42
+ cache_key = cache_key_for(request.url)
43
+ entries = cache.read(cache_key)
44
+ response = lookup_response(request, entries)
45
+ return nil unless response
46
+
47
+ Faraday::HttpCache::Response.new(response)
48
+ end
49
+
50
+ # @param [String] url – the url of a changed resource, will be used to invalidate the cache.
51
+ #
52
+ # @return [void]
53
+ def delete(url)
54
+ cache_key = cache_key_for(url)
55
+ cache.delete(cache_key)
56
+ end
57
+
58
+ private
59
+
60
+ # Retrieve a response Hash from the list of entries that match the given request.
61
+ #
62
+ # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request.
63
+ # @param [Array<Array(Hash, Hash)>] entries - pairs of Hashes (request, response).
64
+ #
65
+ # @return [Hash, nil]
66
+ def lookup_response(request, entries)
67
+ if entries
68
+ entries = entries.map { |entry| deserialize_entry(*entry) }
69
+ _, response = entries.find { |req, res| response_matches?(request, req, res) }
70
+ response
71
+ end
72
+ end
73
+
74
+ # Check if a cached response and request matches the given request.
75
+ #
76
+ # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request.
77
+ # @param [Hash] cached_request - a Hash of the request that was cached.
78
+ # @param [Hash] cached_response - a Hash of the response that was cached.
79
+ #
80
+ # @return [true, false]
81
+ def response_matches?(request, cached_request, cached_response)
82
+ request.method.to_s == cached_request[:method].to_s &&
83
+ vary_matches?(cached_response, request, cached_request)
84
+ end
85
+
86
+ # Check if the cached request matches the incoming
87
+ # request based on the Vary header of cached response.
88
+ #
89
+ # If Vary header is not present, the request is considered to match.
90
+ # If Vary header is '*', the request is considered to not match.
91
+ #
92
+ # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request.
93
+ # @param [Hash] cached_request - a Hash of the request that was cached.
94
+ # @param [Hash] cached_response - a Hash of the response that was cached.
95
+ #
96
+ # @return [true, false]
97
+ def vary_matches?(cached_response, request, cached_request)
98
+ headers = Faraday::Utils::Headers.new(cached_response[:response_headers])
99
+ vary = headers['Vary'].to_s
100
+
101
+ vary.empty? || (vary != '*' && vary.split(/[\s,]+/).all? do |header|
102
+ request.headers[header] == cached_request[:headers][header]
103
+ end)
104
+ end
105
+
106
+ # Computes the cache key for a specific request, taking
107
+ # in account the current serializer to avoid cross serialization issues.
108
+ #
109
+ # @param [String] url - the request URL.
110
+ #
111
+ # @return [String]
112
+ def cache_key_for(url)
113
+ Digest::SHA1.hexdigest("#{@cache_salt}#{url}")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ require 'faraday/http_cache/strategies/base_strategy'
6
+
7
+ module Faraday
8
+ class HttpCache < Faraday::Middleware
9
+ module Strategies
10
+ # This strategy uses headers from the Vary response header to generate cache keys.
11
+ # It also uses the index with Vary headers mapped to the request url.
12
+ # This strategy is more suitable for caching private responses with the same urls,
13
+ # like https://api.github.com/user.
14
+ #
15
+ # This strategy does not support #delete method to clear cache on unsafe methods.
16
+ class ByVary < BaseStrategy
17
+ # Store a response inside the cache.
18
+ #
19
+ # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request.
20
+ # @param [Faraday::HttpCache::Response] response - instance to be stored.
21
+ #
22
+ # @return [void]
23
+ def write(request, response)
24
+ vary_cache_key = vary_cache_key_for(request)
25
+ headers = Faraday::Utils::Headers.new(response.payload[:response_headers])
26
+ vary = headers['Vary'].to_s
27
+ cache.write(vary_cache_key, vary)
28
+
29
+ response_cache_key = response_cache_key_for(request, vary)
30
+ entry = serialize_object(response.serializable_hash)
31
+ cache.write(response_cache_key, entry)
32
+ rescue ::Encoding::UndefinedConversionError => e
33
+ warn "Response could not be serialized: #{e.message}. Try using Marshal to serialize."
34
+ raise e
35
+ end
36
+
37
+ # Fetch a stored response that suits the incoming HTTP request or return nil.
38
+ #
39
+ # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request.
40
+ #
41
+ # @return [Faraday::HttpCache::Response, nil]
42
+ def read(request)
43
+ vary_cache_key = vary_cache_key_for(request)
44
+ vary = cache.read(vary_cache_key)
45
+ return nil if vary.nil? || vary == '*'
46
+
47
+ cache_key = response_cache_key_for(request, vary)
48
+ response = cache.read(cache_key)
49
+ return nil if response.nil?
50
+
51
+ Faraday::HttpCache::Response.new(deserialize_object(response))
52
+ end
53
+
54
+ # This strategy does not support #delete method to clear cache on unsafe methods.
55
+ # @return [void]
56
+ def delete(_url)
57
+ # do nothing since we can't find the key by url
58
+ end
59
+
60
+ private
61
+
62
+ # Computes the cache key for the index with Vary headers.
63
+ #
64
+ # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request.
65
+ #
66
+ # @return [String]
67
+ def vary_cache_key_for(request)
68
+ method = request.method.to_s
69
+ Digest::SHA1.hexdigest("by_vary_index#{@cache_salt}#{method}#{request.url}")
70
+ end
71
+
72
+ # Computes the cache key for the response.
73
+ #
74
+ # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request.
75
+ # @param [String] vary - the Vary header value.
76
+ #
77
+ # @return [String]
78
+ def response_cache_key_for(request, vary)
79
+ method = request.method.to_s
80
+ headers = vary.split(/[\s,]+/).map { |header| request.headers[header] }
81
+ Digest::SHA1.hexdigest("by_vary#{@cache_salt}#{method}#{request.url}#{headers.join}")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday/http_cache/strategies/by_url'
4
+ require 'faraday/http_cache/strategies/by_vary'
@@ -5,11 +5,12 @@ require 'faraday'
5
5
  require 'faraday/http_cache/storage'
6
6
  require 'faraday/http_cache/request'
7
7
  require 'faraday/http_cache/response'
8
+ require 'faraday/http_cache/strategies'
8
9
 
9
10
  module Faraday
10
11
  # Public: The middleware responsible for caching and serving responses.
11
- # The middleware use the provided configuration options to establish a
12
- # 'Faraday::HttpCache::Storage' to cache responses retrieved by the stack
12
+ # The middleware use the provided configuration options to establish on of
13
+ # 'Faraday::HttpCache::Strategies' to cache responses retrieved by the stack
13
14
  # adapter. If a stored response can be served again for a subsequent
14
15
  # request, the middleware will return the response instead of issuing a new
15
16
  # request to it's server. This middleware should be the last attached handler
@@ -91,7 +92,7 @@ module Faraday
91
92
  # Faraday::HttpCache.new(app, logger: my_logger)
92
93
  #
93
94
  # # Initialize the middleware with a logger and Marshal as a serializer
94
- # Faraday:HttpCache.new(app, logger: my_logger, serializer: Marshal)
95
+ # Faraday::HttpCache.new(app, logger: my_logger, serializer: Marshal)
95
96
  #
96
97
  # # Initialize the middleware with a FileStore at the 'tmp' dir.
97
98
  # store = ActiveSupport::Cache.lookup_store(:file_store, ['tmp'])
@@ -104,17 +105,14 @@ module Faraday
104
105
  super(app)
105
106
 
106
107
  options = options.dup
107
- @logger = options.delete(:logger)
108
+ @logger = options[:logger]
108
109
  @shared_cache = options.delete(:shared_cache) { true }
109
110
  @instrumenter = options.delete(:instrumenter)
110
111
  @instrument_name = options.delete(:instrument_name) { EVENT_NAME }
111
112
 
112
- store = options.delete(:store)
113
- serializer = options.delete(:serializer)
113
+ strategy = options.delete(:strategy) { Strategies::ByUrl }
114
114
 
115
- raise ArgumentError, "Unknown options: #{options.inspect}" unless options.empty?
116
-
117
- @storage = Storage.new(store: store, serializer: serializer, logger: @logger)
115
+ @strategy = strategy.new(**options)
118
116
  end
119
117
 
120
118
  # Public: Process the request into a duplicate of this instance to
@@ -159,9 +157,6 @@ module Faraday
159
157
  # Internal: Gets the request object created from the Faraday env Hash.
160
158
  attr_reader :request
161
159
 
162
- # Internal: Gets the storage instance associated with the middleware.
163
- attr_reader :storage
164
-
165
160
  private
166
161
 
167
162
  # Internal: Should this cache instance act like a "shared cache" according
@@ -190,7 +185,7 @@ module Faraday
190
185
  #
191
186
  # Returns the 'Faraday::Response' instance to be served.
192
187
  def process(env)
193
- entry = @storage.read(@request)
188
+ entry = @strategy.read(@request)
194
189
 
195
190
  return fetch(env) if entry.nil?
196
191
 
@@ -264,7 +259,7 @@ module Faraday
264
259
  def store(response)
265
260
  if shared_cache? ? response.cacheable_in_shared_cache? : response.cacheable_in_private_cache?
266
261
  trace :store
267
- @storage.write(@request, response)
262
+ @strategy.write(@request, response)
268
263
  else
269
264
  trace :uncacheable
270
265
  end
@@ -274,10 +269,10 @@ module Faraday
274
269
  headers = %w[Location Content-Location]
275
270
  headers.each do |header|
276
271
  url = response.headers[header]
277
- @storage.delete(url) if url
272
+ @strategy.delete(url) if url
278
273
  end
279
274
 
280
- @storage.delete(request.url)
275
+ @strategy.delete(request.url)
281
276
  trace :delete
282
277
  end
283
278
 
@@ -283,23 +283,13 @@ describe Faraday::HttpCache do
283
283
  expect(client.get('must-revalidate').body).to eq('1')
284
284
  end
285
285
 
286
- it 'raises an error when misconfigured' do
287
- expect {
288
- client = Faraday.new(url: ENV['FARADAY_SERVER']) do |stack|
289
- stack.use Faraday::HttpCache, i_have_no_idea: true
290
- end
291
-
292
- client.get('get')
293
- }.to raise_error(ArgumentError)
294
- end
295
-
296
286
  describe 'Configuration options' do
297
287
  let(:app) { double('it is an app!') }
298
288
 
299
289
  it 'uses the options to create a Cache Store' do
300
290
  store = double(read: nil, write: nil)
301
291
 
302
- expect(Faraday::HttpCache::Storage).to receive(:new).with(hash_including(store: store))
292
+ expect(Faraday::HttpCache::Strategies::ByUrl).to receive(:new).with(hash_including(store: store))
303
293
  Faraday::HttpCache.new(app, store: store)
304
294
  end
305
295
  end
data/spec/storage_spec.rb CHANGED
@@ -16,6 +16,21 @@ describe Faraday::HttpCache::Storage do
16
16
  let(:storage) { Faraday::HttpCache::Storage.new(store: cache) }
17
17
  subject { storage }
18
18
 
19
+ before do
20
+ allow(Kernel).to receive(:warn).with(
21
+ 'Deprecated: Faraday::HttpCache::Storage is deprecated and will be removed '\
22
+ 'in the next major release. Use Faraday::HttpCache::Strategies::ByUrl instead.'
23
+ )
24
+ end
25
+
26
+ it 'creates strategy and warns about deprecation' do
27
+ expect(Kernel).to receive(:warn).with(
28
+ 'Deprecated: Faraday::HttpCache::Storage is deprecated and will be removed '\
29
+ 'in the next major release. Use Faraday::HttpCache::Strategies::ByUrl instead.'
30
+ )
31
+ is_expected.to be_a_kind_of(Faraday::HttpCache::Strategies::ByUrl)
32
+ end
33
+
19
34
  describe 'Cache configuration' do
20
35
  it 'uses a MemoryStore by default' do
21
36
  expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original
@@ -128,9 +143,10 @@ describe Faraday::HttpCache::Storage do
128
143
  end
129
144
 
130
145
  it 'is fresh until cached and that 1 second elapses then the response is no longer fresh' do
146
+ current_time = Time.now
131
147
  headers = {
132
- 'Date' => (Time.now - 39).httpdate,
133
- 'Expires' => (Time.now + 40).httpdate
148
+ 'Date' => (current_time - 39).httpdate,
149
+ 'Expires' => (current_time + 40).httpdate
134
150
  }
135
151
 
136
152
  response = Faraday::HttpCache::Response.new(response_headers: headers)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Faraday::HttpCache::Strategies::BaseStrategy do
6
+ subject(:strategy) { described_class.new }
7
+
8
+ it 'uses a MemoryStore as a default store' do
9
+ expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original
10
+ strategy
11
+ end
12
+
13
+ context 'when the given store is not valid' do
14
+ let(:store) { double(:wrong_store) }
15
+ subject(:strategy) { described_class.new(store: store) }
16
+
17
+ it 'raises an error' do
18
+ expect { strategy }.to raise_error(ArgumentError)
19
+ end
20
+ end
21
+
22
+ it 'raises an error when abstract methods are called' do
23
+ expect { strategy.write(nil, nil) }.to raise_error(NotImplementedError)
24
+ expect { strategy.read(nil) }.to raise_error(NotImplementedError)
25
+ expect { strategy.delete(nil) }.to raise_error(NotImplementedError)
26
+ end
27
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Faraday::HttpCache::Strategies::ByUrl do
6
+ let(:cache_key) { '6e3b941d0f7572291c777b3e48c04b74124a55d0' }
7
+ let(:request) do
8
+ env = { method: :get, url: 'http://test/index' }
9
+ double(env.merge(serializable_hash: env))
10
+ end
11
+
12
+ let(:response) { double(serializable_hash: { response_headers: {} }) }
13
+
14
+ let(:cache) { Faraday::HttpCache::MemoryStore.new }
15
+
16
+ let(:strategy) { described_class.new(store: cache) }
17
+ subject { strategy }
18
+
19
+ describe 'Cache configuration' do
20
+ it 'uses a MemoryStore by default' do
21
+ expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original
22
+ described_class.new
23
+ end
24
+
25
+ it 'raises an error when the given store is not valid' do
26
+ wrong = double
27
+
28
+ expect {
29
+ described_class.new(store: wrong)
30
+ }.to raise_error(ArgumentError)
31
+ end
32
+ end
33
+
34
+ describe 'storing responses' do
35
+ shared_examples 'A strategy with serialization' do
36
+ it 'writes the response object to the underlying cache' do
37
+ entry = [serializer.dump(request.serializable_hash), serializer.dump(response.serializable_hash)]
38
+ expect(cache).to receive(:write).with(cache_key, [entry])
39
+ subject.write(request, response)
40
+ end
41
+ end
42
+
43
+ context 'with the JSON serializer' do
44
+ let(:serializer) { JSON }
45
+ it_behaves_like 'A strategy with serialization'
46
+
47
+ context 'when ASCII characters in response cannot be converted to UTF-8', if: Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1') do
48
+ let(:response) do
49
+ body = String.new("\u2665").force_encoding('ASCII-8BIT')
50
+ double(:response, serializable_hash: { 'body' => body })
51
+ end
52
+
53
+ it 'raises and logs a warning' do
54
+ logger = double(:logger, warn: nil)
55
+ strategy = described_class.new(logger: logger)
56
+
57
+ expect {
58
+ strategy.write(request, response)
59
+ }.to raise_error(::Encoding::UndefinedConversionError)
60
+ expect(logger).to have_received(:warn).with(
61
+ 'Response could not be serialized: "\xE2" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.'
62
+ )
63
+ end
64
+ end
65
+ end
66
+
67
+ context 'with the Marshal serializer' do
68
+ let(:cache_key) { '337d1e9c6c92423dd1c48a23054139058f97be40' }
69
+ let(:serializer) { Marshal }
70
+ let(:strategy) { described_class.new(store: cache, serializer: Marshal) }
71
+
72
+ it_behaves_like 'A strategy with serialization'
73
+ end
74
+ end
75
+
76
+ describe 'reading responses' do
77
+ let(:strategy) { described_class.new(store: cache, serializer: serializer) }
78
+
79
+ shared_examples 'A strategy with serialization' do
80
+ it 'returns nil if the response is not cached' do
81
+ expect(subject.read(request)).to be_nil
82
+ end
83
+
84
+ it 'decodes a stored response' do
85
+ subject.write(request, response)
86
+
87
+ expect(subject.read(request)).to be_a(Faraday::HttpCache::Response)
88
+ end
89
+ end
90
+
91
+ context 'with the JSON serializer' do
92
+ let(:serializer) { JSON }
93
+
94
+ it_behaves_like 'A strategy with serialization'
95
+ end
96
+
97
+ context 'with the Marshal serializer' do
98
+ let(:serializer) { Marshal }
99
+
100
+ it_behaves_like 'A strategy with serialization'
101
+ end
102
+ end
103
+
104
+ describe 'deleting responses' do
105
+ it 'removes the entries from the cache of the given URL' do
106
+ subject.write(request, response)
107
+ subject.delete(request.url)
108
+ expect(subject.read(request)).to be_nil
109
+ end
110
+ end
111
+
112
+ describe 'remove age before caching and normalize max-age if non-zero age present' do
113
+ it 'is fresh if the response still has some time to live' do
114
+ headers = {
115
+ 'Age' => 6,
116
+ 'Cache-Control' => 'public, max-age=40',
117
+ 'Date' => (Time.now - 38).httpdate,
118
+ 'Expires' => (Time.now - 37).httpdate,
119
+ 'Last-Modified' => (Time.now - 300).httpdate
120
+ }
121
+ response = Faraday::HttpCache::Response.new(response_headers: headers)
122
+ expect(response).to be_fresh
123
+ subject.write(request, response)
124
+
125
+ cached_response = subject.read(request)
126
+ expect(cached_response.max_age).to eq(34)
127
+ expect(cached_response).not_to be_fresh
128
+ end
129
+
130
+ it 'is fresh until cached and that 1 second elapses then the response is no longer fresh' do
131
+ headers = {
132
+ 'Date' => (Time.now - 39).httpdate,
133
+ 'Expires' => (Time.now + 40).httpdate
134
+ }
135
+
136
+ response = Faraday::HttpCache::Response.new(response_headers: headers)
137
+ expect(response).to be_fresh
138
+ subject.write(request, response)
139
+
140
+ sleep(1)
141
+ cached_response = subject.read(request)
142
+ expect(cached_response).not_to be_fresh
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Faraday::HttpCache::Strategies::ByVary do
6
+ let(:vary_index_cache_key) { '64896419583e8022efeb21d0ece6e266c0e58b59' }
7
+ let(:cache_key) { '25230d75622fffc4f4de8a6af69e6e3764f7eb6f' }
8
+ let(:vary) { '' }
9
+ let(:request) do
10
+ env = {method: :get, url: 'http://test/index'}
11
+ double(env.merge(serializable_hash: env))
12
+ end
13
+
14
+ let(:response_payload) { {response_headers: {'Vary' => vary}} }
15
+
16
+ let(:response) do
17
+ instance_double(Faraday::HttpCache::Response, payload: response_payload, serializable_hash: response_payload)
18
+ end
19
+
20
+ let(:cache) { Faraday::HttpCache::MemoryStore.new }
21
+
22
+ let(:strategy) { described_class.new(store: cache) }
23
+ subject { strategy }
24
+
25
+ describe 'storing responses' do
26
+ shared_examples 'A strategy with serialization' do
27
+ it 'writes the response object to the underlying cache' do
28
+ entry = serializer.dump(response.serializable_hash)
29
+ expect(cache).to receive(:write).with(vary_index_cache_key, vary)
30
+ expect(cache).to receive(:write).with(cache_key, entry)
31
+ subject.write(request, response)
32
+ end
33
+ end
34
+
35
+ context 'with the JSON serializer' do
36
+ let(:serializer) { JSON }
37
+ it_behaves_like 'A strategy with serialization'
38
+
39
+ context 'when ASCII characters in response cannot be converted to UTF-8', if: Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1') do
40
+ let(:response_payload) do
41
+ body = String.new("\u2665").force_encoding('ASCII-8BIT')
42
+ super().merge('body' => body)
43
+ end
44
+
45
+ it 'raises and logs a warning' do
46
+ logger = double(:logger, warn: nil)
47
+ strategy = described_class.new(logger: logger)
48
+
49
+ expect {
50
+ strategy.write(request, response)
51
+ }.to raise_error(::Encoding::UndefinedConversionError)
52
+ expect(logger).to have_received(:warn).with(
53
+ 'Response could not be serialized: "\xE2" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.'
54
+ )
55
+ end
56
+ end
57
+ end
58
+
59
+ context 'with the Marshal serializer' do
60
+ let(:vary_index_cache_key) { '6a7cb42440c10ef6edeb1826086a4d90b04103f0' }
61
+ let(:cache_key) { '45e0efd1a60d29ed69d6c6018dfcb96f58db89e0' }
62
+ let(:serializer) { Marshal }
63
+ let(:strategy) { described_class.new(store: cache, serializer: Marshal) }
64
+
65
+ it_behaves_like 'A strategy with serialization'
66
+ end
67
+ end
68
+
69
+ describe 'reading responses' do
70
+ let(:strategy) { described_class.new(store: cache, serializer: serializer) }
71
+
72
+ shared_examples 'A strategy with serialization' do
73
+ it 'returns nil if the response is not cached' do
74
+ expect(subject.read(request)).to be_nil
75
+ end
76
+
77
+ it 'decodes a stored response' do
78
+ subject.write(request, response)
79
+
80
+ expect(subject.read(request)).to be_a(Faraday::HttpCache::Response)
81
+ end
82
+ end
83
+
84
+ context 'with the JSON serializer' do
85
+ let(:serializer) { JSON }
86
+
87
+ it_behaves_like 'A strategy with serialization'
88
+ end
89
+
90
+ context 'with the Marshal serializer' do
91
+ let(:serializer) { Marshal }
92
+
93
+ it_behaves_like 'A strategy with serialization'
94
+ end
95
+ end
96
+
97
+ describe 'deleting responses' do
98
+ it 'ignores delete method' do
99
+ subject.write(request, response)
100
+ subject.delete(request.url)
101
+ expect(subject.read(request)).not_to be_nil
102
+ end
103
+ end
104
+
105
+ describe 'remove age before caching and normalize max-age if non-zero age present' do
106
+ it 'is fresh if the response still has some time to live' do
107
+ headers = {
108
+ 'Age' => 6,
109
+ 'Cache-Control' => 'public, max-age=40',
110
+ 'Date' => (Time.now - 38).httpdate,
111
+ 'Expires' => (Time.now - 37).httpdate,
112
+ 'Last-Modified' => (Time.now - 300).httpdate
113
+ }
114
+ response = Faraday::HttpCache::Response.new(response_headers: headers)
115
+ expect(response).to be_fresh
116
+ subject.write(request, response)
117
+
118
+ cached_response = subject.read(request)
119
+ expect(cached_response.max_age).to eq(34)
120
+ expect(cached_response).not_to be_fresh
121
+ end
122
+
123
+ it 'is fresh until cached and that 1 second elapses then the response is no longer fresh' do
124
+ headers = {
125
+ 'Date' => (Time.now - 39).httpdate,
126
+ 'Expires' => (Time.now + 40).httpdate
127
+ }
128
+
129
+ response = Faraday::HttpCache::Response.new(response_headers: headers)
130
+ expect(response).to be_fresh
131
+ subject.write(request, response)
132
+
133
+ sleep(1)
134
+ cached_response = subject.read(request)
135
+ expect(cached_response).not_to be_fresh
136
+ end
137
+ end
138
+ end
metadata CHANGED
@@ -1,15 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faraday-http-cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
+ - Lucas Mazza
7
8
  - George Guimarães
8
9
  - Gustavo Araujo
9
10
  autorequire:
10
11
  bindir: bin
11
12
  cert_chain: []
12
- date: 2022-05-25 00:00:00.000000000 Z
13
+ date: 2022-06-08 00:00:00.000000000 Z
13
14
  dependencies:
14
15
  - !ruby/object:Gem::Dependency
15
16
  name: faraday
@@ -37,9 +38,14 @@ files:
37
38
  - lib/faraday-http-cache.rb
38
39
  - lib/faraday/http_cache.rb
39
40
  - lib/faraday/http_cache/cache_control.rb
41
+ - lib/faraday/http_cache/memory_store.rb
40
42
  - lib/faraday/http_cache/request.rb
41
43
  - lib/faraday/http_cache/response.rb
42
44
  - lib/faraday/http_cache/storage.rb
45
+ - lib/faraday/http_cache/strategies.rb
46
+ - lib/faraday/http_cache/strategies/base_strategy.rb
47
+ - lib/faraday/http_cache/strategies/by_url.rb
48
+ - lib/faraday/http_cache/strategies/by_vary.rb
43
49
  - spec/binary_spec.rb
44
50
  - spec/cache_control_spec.rb
45
51
  - spec/http_cache_spec.rb
@@ -49,6 +55,9 @@ files:
49
55
  - spec/response_spec.rb
50
56
  - spec/spec_helper.rb
51
57
  - spec/storage_spec.rb
58
+ - spec/strategies/base_strategy_spec.rb
59
+ - spec/strategies/by_url_spec.rb
60
+ - spec/strategies/by_vary_spec.rb
52
61
  - spec/support/empty.png
53
62
  - spec/support/test_app.rb
54
63
  - spec/support/test_server.rb
@@ -72,21 +81,24 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
81
  - !ruby/object:Gem::Version
73
82
  version: '0'
74
83
  requirements: []
75
- rubygems_version: 3.0.3
84
+ rubygems_version: 3.2.3
76
85
  signing_key:
77
86
  specification_version: 4
78
87
  summary: A Faraday middleware that stores and validates cache expiration.
79
88
  test_files:
80
- - spec/spec_helper.rb
81
- - spec/validation_spec.rb
82
- - spec/json_spec.rb
89
+ - spec/binary_spec.rb
90
+ - spec/cache_control_spec.rb
91
+ - spec/http_cache_spec.rb
83
92
  - spec/instrumentation_spec.rb
93
+ - spec/json_spec.rb
94
+ - spec/request_spec.rb
95
+ - spec/response_spec.rb
96
+ - spec/spec_helper.rb
84
97
  - spec/storage_spec.rb
85
- - spec/http_cache_spec.rb
86
- - spec/binary_spec.rb
87
- - spec/support/test_app.rb
98
+ - spec/strategies/base_strategy_spec.rb
99
+ - spec/strategies/by_url_spec.rb
100
+ - spec/strategies/by_vary_spec.rb
88
101
  - spec/support/empty.png
102
+ - spec/support/test_app.rb
89
103
  - spec/support/test_server.rb
90
- - spec/request_spec.rb
91
- - spec/cache_control_spec.rb
92
- - spec/response_spec.rb
104
+ - spec/validation_spec.rb