faraday-http-cache 0.0.1.dev

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.
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2012 Plataformatec.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # Faraday Http Cache
2
+ a [Faraday](https://github.com/technoweenie/faraday) middleware that respects HTTP cache,
3
+ by checking expiration and validation of the stored responses.
4
+
5
+ ## Installation
6
+
7
+ Add it to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'faraday-http-cache'
11
+ ```
12
+
13
+ ## Usage and configuration
14
+
15
+ You have to use the middleware in the Faraday instance that you want to. You can use the new
16
+ shortcut using a symbol or passing the middleware class
17
+
18
+ ```ruby
19
+ client = Faraday.new do |builder|
20
+ builder.use :http_cache
21
+ # or
22
+ builder.use Faraday::HttpCache
23
+
24
+ builder.adapter Faraday.default_adapter
25
+ end
26
+ ```
27
+
28
+ The middleware uses the `ActiveSupport::Cache` API to record the responses from the targeted
29
+ endpoints, and any extra configuration option will be used to setup the cache store.
30
+
31
+ ```ruby
32
+ # Connect the middleware to a Memcache instance.
33
+ client = Faraday.new do |builder|
34
+ builder.use :http_cache, :mem_cache_store, "localhost:11211"
35
+ builder.adapter Faraday.default_adapter
36
+ end
37
+
38
+ # Or use the Rails.cache instance inside your Rails app.
39
+ client = Faraday.new do |builder|
40
+ builder.use :http_cache, Rails.cache
41
+ builder.adapter Faraday.default_adapter
42
+ end
43
+ ```
44
+
45
+ The default store provided by ActiveSupport is the `MemoryStore` one, so it's important to
46
+ configure a proper one for your production environment.
47
+
48
+ ### Logging
49
+
50
+ You can provide a `:logger` option that will be receive debug informations based on the middleware
51
+ operations:
52
+
53
+ ```ruby
54
+ client = Faraday.new do |builder|
55
+ builder.use :http_cache, :logger => Rails.logger
56
+ builder.adapter Faraday.default_adapter
57
+ end
58
+
59
+ client.get('http://site/api/users')
60
+ # logs "HTTP Cache: [GET users] miss, store"
61
+ ```
62
+
63
+ ## See it live
64
+
65
+ You can clone this repository, install it's dependencies with Bundler (run `bundle install`) and
66
+ execute the `examples/twitter.rb` file to see a sample of the middleware usage - it's issuing
67
+ requests to the Twitter API and caching them, so the rate limit isn't reduced on every request by
68
+ the client object. After sleeping for 5 minutes the cache will expire and the client will hit the
69
+ Twitter API again.
70
+
71
+ ## License
72
+
73
+ Copyright (c) 2012 Plataformatec. See LICENSE file.
@@ -0,0 +1,112 @@
1
+ module Faraday
2
+ class HttpCache < Faraday::Middleware
3
+ # Internal: a class to represent the 'Cache-Control' header options.
4
+ # This implementation is based on 'rack-cache' internals by Ryan Tomayko.
5
+ # It breaks the several directives into keys/values and stores them into
6
+ # a Hash.
7
+ class CacheControl
8
+
9
+ # Internal: Initialize a new CacheControl.
10
+ def initialize(string)
11
+ @directives = {}
12
+ parse(string)
13
+ end
14
+
15
+ # Internal: Checks if the 'public' directive is present.
16
+ def public?
17
+ @directives['public']
18
+ end
19
+
20
+ # Internal: Checks if the 'private' directive is present.
21
+ def private?
22
+ @directives['private']
23
+ end
24
+
25
+ # Internal: Checks if the 'no-cache' directive is present.
26
+ def no_cache?
27
+ @directives['no-cache']
28
+ end
29
+
30
+ # Internal: Checks if the 'no-store' directive is present.
31
+ def no_store?
32
+ @directives['no-store']
33
+ end
34
+
35
+ # Internal: Gets the 'max-age' directive as an Integer.
36
+ #
37
+ # Returns nil if the 'max-age' directive isn't present.
38
+ def max_age
39
+ @directives['max-age'].to_i if @directives.key?('max-age')
40
+ end
41
+
42
+ # Internal: Gets the 's-maxage' directive as an Integer.
43
+ #
44
+ # Returns nil if the 's-maxage' directive isn't present.
45
+ def shared_max_age
46
+ @directives['s-maxage'].to_i if @directives.key?('s-maxage')
47
+ end
48
+ alias_method :s_maxage, :shared_max_age
49
+
50
+ # Internal: Checks if the 'must-revalidate' directive is present.
51
+ def must_revalidate?
52
+ @directives['must-revalidate']
53
+ end
54
+
55
+ # Internal: Checks if the 'proxy-revalidate' directive is present.
56
+ def proxy_revalidate?
57
+ @directives['proxy-revalidate']
58
+ end
59
+
60
+ # Internal: Gets the String representation for the cache directives.
61
+ # Directives are joined by a '=' and then combined into a single String
62
+ # separated by commas. Directives with a 'true' value will omit the '='
63
+ # sign and their value.
64
+ #
65
+ # Returns the Cache Control string.
66
+ def to_s
67
+ booleans, values = [], []
68
+
69
+ @directives.each do |key, value|
70
+ if value == true
71
+ booleans << key
72
+ elsif value
73
+ values << "#{key}=#{value}"
74
+ end
75
+ end
76
+
77
+ (booleans.sort + values.sort).join(', ')
78
+ end
79
+
80
+ private
81
+
82
+ # Internal: Parses the Cache Control string into the directives Hash.
83
+ # Existing whitespaces are removed, and the string is splited on commas.
84
+ # For each segment, everything before a '=' will be treated as the key
85
+ # and the excedding will be treated as the value. If only the key is
86
+ # present the assigned value will defaults to true.
87
+ #
88
+ # Examples:
89
+ # parse("max-age=600")
90
+ # @directives
91
+ # # => { "max-age" => "600"}
92
+ #
93
+ # parse("max-age")
94
+ # @directives
95
+ # # => { "max-age" => true }
96
+ #
97
+ # Returns nothing.
98
+ def parse(string)
99
+ string = string.to_s
100
+
101
+ return if string.empty?
102
+
103
+ string.delete(' ').split(',').each do |part|
104
+ next if part.empty?
105
+
106
+ name, value = part.split('=', 2)
107
+ @directives[name.downcase] = (value || true) unless name.empty?
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,170 @@
1
+ require 'time'
2
+ require 'faraday/http_cache/cache_control'
3
+
4
+ module Faraday
5
+ class HttpCache < Faraday::Middleware
6
+ # Internal: a class to represent a response from a Faraday request.
7
+ # It decorates the response hash into a smarter object that queries
8
+ # the response headers and status informations about how the caching
9
+ # middleware should handle this specific response.
10
+ class Response
11
+ # Internal: List of status codes that can be cached:
12
+ # * 200 - 'OK'
13
+ # * 203 - 'Non-Authoritative Information'
14
+ # * 300 - 'Multiple Choices'
15
+ # * 301 - 'Moved Permanently'
16
+ # * 302 - 'Found'
17
+ # * 404 - 'Not Found'
18
+ # * 410 - 'Gone'
19
+ CACHEABLE_STATUS_CODES = [200, 203, 300, 301, 302, 404, 410]
20
+
21
+ # Internal: Gets the actual response Hash (status, headers and body).
22
+ attr_reader :payload
23
+
24
+ # Internal: Gets the 'Last-Modified' header from the headers Hash.
25
+ attr_reader :last_modified
26
+
27
+ # Internal: Gets the 'ETag' header from the headers Hash.
28
+ attr_reader :etag
29
+
30
+ # Internal: Initialize a new Response with the response payload from
31
+ # a Faraday request.
32
+ #
33
+ # payload - the response Hash returned by a Faraday request.
34
+ # :status - the status code from the response.
35
+ # :response_headers - a 'Hash' like object with the headers.
36
+ # :body - the response body.
37
+ def initialize(payload = {})
38
+ @now = Time.now
39
+ @payload = payload
40
+ wrap_headers!
41
+ headers['Date'] ||= @now.httpdate
42
+
43
+ @last_modified = headers['Last-Modified']
44
+ @etag = headers['ETag']
45
+ end
46
+
47
+ # Internal: Checks the response freshness based on expiration headers.
48
+ # The calculated 'ttl' should be present and bigger than 0.
49
+ #
50
+ # Returns true if the response is fresh, otherwise false.
51
+ def fresh?
52
+ ttl && ttl > 0
53
+ end
54
+
55
+ # Internal: Checks if the Response returned a 'Not Modified' status.
56
+ #
57
+ # Returns true if the response status code is 304.
58
+ def not_modified?
59
+ @payload[:status] == 304
60
+ end
61
+
62
+ # Internal: Checks if the response can be cached by the client.
63
+ # This is validated by the 'Cache-Control' directives, the response
64
+ # status code and it's freshness or validation status.
65
+ #
66
+ # Returns false if the 'Cache-Control' says that we can't store the
67
+ # response, or if isn't fresh or it can't be revalidated with the origin
68
+ # server. Otherwise, returns true.
69
+ def cacheable?
70
+ return false if cache_control.private? || cache_control.no_store?
71
+
72
+ cacheable_status_code? && (validateable? || fresh?)
73
+ end
74
+
75
+ # Internal: Gets the response age in seconds.
76
+ #
77
+ # Returns the 'Age' header if present, or subtracts the response 'date'
78
+ # from the current time.
79
+ def age
80
+ (headers['Age'] || (@now - date)).to_i
81
+ end
82
+
83
+ # Internal: Calculates the 'Time to live' left on the Response.
84
+ #
85
+ # Returns the remaining seconds for the response, or nil the 'max_age'
86
+ # isn't present.
87
+ def ttl
88
+ max_age - age if max_age
89
+ end
90
+
91
+ # Internal: Parses the 'Date' header back into a Time instance.
92
+ #
93
+ # Returns the Time object.
94
+ def date
95
+ Time.httpdate(headers['Date'])
96
+ end
97
+
98
+ # Internal: Gets the response max age.
99
+ # The max age is extracted from one of the following:
100
+ # * The shared max age directive from the 'Cache-Control' header;
101
+ # * The max age directive from the 'Cache-Control' header;
102
+ # * The difference between the 'Expires' header and the response
103
+ # date.
104
+ #
105
+ # Returns the max age value in seconds or nil if all options above fails.
106
+ def max_age
107
+ cache_control.shared_max_age ||
108
+ cache_control.max_age ||
109
+ (expires && (expires - date))
110
+ end
111
+
112
+ # Internal: Creates a new 'Faraday::Response'.
113
+ #
114
+ # Returns a new instance of a 'Faraday::Response' with the payload.
115
+ def to_response
116
+ Faraday::Response.new(@payload)
117
+ end
118
+
119
+ private
120
+
121
+ # Internal: Checks if this response can be revalidated.
122
+ #
123
+ # Returns true if the 'headers' contains a 'Last-Modified' or an 'ETag'
124
+ # entry.
125
+ def validateable?
126
+ headers.key?('Last-Modified') || headers.key?('ETag')
127
+ end
128
+
129
+ # Internal: Validates the response status against the
130
+ # `CACHEABLE_STATUS_CODES' constant.
131
+ #
132
+ # Returns true if the constant includes the response status code.
133
+ def cacheable_status_code?
134
+ CACHEABLE_STATUS_CODES.include?(@payload[:status])
135
+ end
136
+
137
+ # Internal: Gets the 'Expires' in a Time object.
138
+ #
139
+ # Returns the Time object, or nil if the header isn't present.
140
+ def expires
141
+ headers['Expires'] && Time.httpdate(headers['Expires'])
142
+ end
143
+
144
+ # Internal: Gets the 'CacheControl' object.
145
+ def cache_control
146
+ @cache_control ||= CacheControl.new(headers['Cache-Control'])
147
+ end
148
+
149
+ # Internal: Converts the headers 'Hash' into 'Faraday::Utils::Headers'.
150
+ # Faraday actually uses a Hash subclass, `Faraday::Utils::Headers` to
151
+ # store the headers hash. When retrieving a serialized response,
152
+ # the headers object is decoded as a 'Hash' instead of the actual
153
+ # 'Faraday::Utils::Headers' object, so we need to ensure that the
154
+ # 'response_headers' is always a 'Headers' instead of a plain 'Hash'.
155
+ #
156
+ # Returns nothing.
157
+ def wrap_headers!
158
+ headers = @payload[:response_headers]
159
+
160
+ @payload[:response_headers] = Faraday::Utils::Headers.new
161
+ @payload[:response_headers].update(headers) if headers
162
+ end
163
+
164
+ # Internal: Gets the headers 'Hash' from the payload.
165
+ def headers
166
+ @payload[:response_headers]
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,71 @@
1
+ require 'digest/sha1'
2
+ require 'active_support/cache'
3
+ require 'active_support/core_ext/hash/keys'
4
+
5
+ module Faraday
6
+ class HttpCache < Faraday::Middleware
7
+ # Internal: A Wrapper around a ActiveSupport::CacheStore to store responses.
8
+ #
9
+ # Examples
10
+ # # Creates a new Storage using a MemCached backend from ActiveSupport.
11
+ # Faraday::HttpCache::Storage.new(:mem_cache_store)
12
+ #
13
+ # # Reuse some other instance of a ActiveSupport::CacheStore object.
14
+ # Faraday::HttpCache::Storage.new(Rails.cache)
15
+ class Storage
16
+ attr_reader :cache
17
+
18
+ # Internal: Initialize a new Storage object with a cache backend.
19
+ #
20
+ # store - An ActiveSupport::CacheStore identifier (default: nil).
21
+ # options - The Hash options for the CacheStore backend (default: {}).
22
+ def initialize(store = nil, options = {})
23
+ @cache = ActiveSupport::Cache.lookup_store(store, options)
24
+ end
25
+
26
+ # Internal: Writes a response with a key based on the given request.
27
+ #
28
+ # request - The Hash containing the request information.
29
+ # :method - The HTTP Method used for the request.
30
+ # :url - The requested URL.
31
+ # :request_headers - The custom headers for the request.
32
+ # response - The Faraday::HttpCache::Response instance to be stored.
33
+ def write(request, response)
34
+ key = cache_key_for(request)
35
+ value = MultiJson.dump(response.payload)
36
+
37
+ cache.write(key, value)
38
+ end
39
+
40
+ # Internal: Reads a key based on the given request from the underlying cache.
41
+ #
42
+ # request - The Hash containing the request information.
43
+ # :method - The HTTP Method used for the request.
44
+ # :url - The requested URL.
45
+ # :request_headers - The custom headers for the request.
46
+ # klass - The Class to be instantiated with the recovered informations.
47
+ def read(request, klass = Faraday::HttpCache::Response)
48
+ key = cache_key_for(request)
49
+ value = cache.read(key)
50
+
51
+ if value
52
+ payload = MultiJson.load(value).symbolize_keys
53
+ klass.new(payload)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # Internal: Generates a String key for a given request object.
60
+ # The request object is folded into a sorted Array (since we can't count
61
+ # on hashes order on Ruby 1.8), encoded as JSON and digested as a `SHA1`
62
+ # string.
63
+ #
64
+ # Returns the encoded String.
65
+ def cache_key_for(request)
66
+ array = request.stringify_keys.to_a.sort
67
+ Digest::SHA1.hexdigest(MultiJson.dump(array))
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,228 @@
1
+ require 'faraday'
2
+ require 'multi_json'
3
+
4
+ require 'active_support/core_ext/hash/slice'
5
+
6
+ require 'faraday/http_cache/storage'
7
+ require 'faraday/http_cache/response'
8
+
9
+ module Faraday
10
+ # 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
13
+ # adapter. If a stored response can be served again for a subsequent
14
+ # request, the middleware will return the response instead of issuing a new
15
+ # request to it's server. This middleware should be the last attached handler
16
+ # to your stack, so it will be closest to the inner app, avoiding issues
17
+ # with other middlewares on your stack.
18
+ #
19
+ # Examples:
20
+ #
21
+ # # Using the middleware with a simple client:
22
+ # client = Faraday.new do |builder|
23
+ # builder.user :http_cache
24
+ # builder.adapter Faraday.default_adapter
25
+ # end
26
+ #
27
+ # # Attach a Logger to the middleware.
28
+ # client = Faraday.new do |builder|
29
+ # builder.use :http_cache, :logger => my_logger_instance
30
+ # builder.adapter Faraday.default_adapter
31
+ # end
32
+ #
33
+ # # Provide an existing CacheStore (for instance, from a Rails app)
34
+ # client = Faraday.new do |builder|
35
+ # builder.use :http_cache, Rails.cache
36
+ # end
37
+ class HttpCache < Faraday::Middleware
38
+
39
+ # Public: Initializes a new HttpCache middleware.
40
+ #
41
+ # app - the next endpoint on the 'Faraday' stack.
42
+ # arguments - aditional options to setup the logger and the storage.
43
+ #
44
+ # Examples:
45
+ #
46
+ # # Initialize the middleware with a logger.
47
+ # Faraday::HttpCache.new(app, :logger => my_logger)
48
+ #
49
+ # # Initialize the middleware with a FileStore at the 'tmp' dir.
50
+ # Faraday::HttpCache.new(app, :file_store, 'tmp')
51
+ def initialize(app, *arguments)
52
+ super(app)
53
+
54
+ if arguments.last.is_a? Hash
55
+ options = arguments.pop
56
+ @logger = options.delete(:logger)
57
+ else
58
+ options = arguments
59
+ end
60
+
61
+ store = arguments.shift
62
+
63
+ @storage = Storage.new(store, options)
64
+ end
65
+
66
+ # Public: Process the request into a duplicate of this instance to
67
+ # ensure that the internal state is preserved.
68
+ def call(env)
69
+ dup.call!(env)
70
+ end
71
+
72
+ # Internal: Process the stack request to try to serve a cache response.
73
+ # On a cacheable request, the middleware will attempt to locate a
74
+ # valid stored response to serve. On a cache miss, the middleware will
75
+ # forward the request and try to store the response for future requests.
76
+ # If the request can't be cached, the request will be delegated directly
77
+ # to the underlying app and does nothing to the response.
78
+ # The processed steps will be recorded to be logged once the whole
79
+ # process is finished.
80
+ #
81
+ # Returns a 'Faraday::Response' instance.
82
+ def call!(env)
83
+ @trace = []
84
+ @request = create_request(env)
85
+
86
+ response = nil
87
+
88
+ if can_cache?(@request[:method])
89
+ response = process(env)
90
+ else
91
+ trace :unacceptable
92
+ response = @app.call(env)
93
+ end
94
+
95
+ response.on_complete do
96
+ log_request
97
+ end
98
+ end
99
+
100
+ private
101
+ # Internal: Validates if the current request method is valid for caching.
102
+ #
103
+ # Returns true if the method is ':get' or ':head'.
104
+ def can_cache?(method)
105
+ method == :get || method == :head
106
+ end
107
+
108
+ # Internal: Tries to located a valid response or forwards the call to the stack.
109
+ # * If no entry is present on the storage, the 'fetch' method will forward
110
+ # the call to the remaining stack and return the new response.
111
+ # * If a fresh response is found, the middleware will abort the remaining
112
+ # stack calls and return the stored response back to the client.
113
+ # * If a response is found but isn't fresh anymore, the middleware will
114
+ # revalidate the response back to the server.
115
+ #
116
+ # env - the environment 'Hash' provided from the 'Faraday' stack.
117
+ #
118
+ # Returns the actual 'Faraday::Response' instance to be served.
119
+ def process(env)
120
+ entry = @storage.read(@request)
121
+
122
+ return fetch(env) if entry.nil?
123
+
124
+ if entry.fresh?
125
+ response = entry.to_response
126
+ trace :fresh
127
+ else
128
+ response = validate(entry, env)
129
+ end
130
+
131
+ response
132
+ end
133
+
134
+ # Internal: Tries to validated a stored entry back to it's origin server
135
+ # using the 'If-Modified-Since' and 'If-None-Match' headers with the
136
+ # existing 'Last-Modified' and 'ETag' headers. If the new response
137
+ # is marked as 'Not Modified', the previous stored response will be used
138
+ # and forwarded against the Faraday stack. Otherwise, the freshly new
139
+ # response will be stored (replacing the old one) and used.
140
+ #
141
+ # entry - a stale 'Faraday::HttpCache::Response' retrieved from the cache.
142
+ # env - the environment 'Hash' to perform the request.
143
+ #
144
+ # Returns the 'Faraday::HttpCache::Response' to be forwarded into the stack.
145
+ def validate(entry, env)
146
+ headers = env[:request_headers]
147
+ headers['If-Modified-Since'] = entry.last_modified if entry.last_modified
148
+ headers['If-None-Match'] = entry.etag if entry.etag
149
+
150
+ @app.call(env).on_complete do |env|
151
+ response = Response.new(env)
152
+ if response.not_modified?
153
+ trace :valid
154
+ env.merge!(entry.payload)
155
+ response = entry
156
+ end
157
+ store(response)
158
+ end
159
+ end
160
+
161
+ # Internal: Records a traced action to be used by the logger once the
162
+ # request/response phase is finished.
163
+ #
164
+ # operation - the name of the performed action, a String or Symbol.
165
+ #
166
+ # Returns nothing.
167
+ def trace(operation)
168
+ @trace << operation
169
+ end
170
+
171
+ # Internal: Stores the response into the storage.
172
+ # If the response isn't cacheable, a trace action 'invalid' will be
173
+ # recorded for logging purposes.
174
+ #
175
+ # response - a 'Faraday::HttpCache::Response' instance to be stored.
176
+ #
177
+ # Returns nothing.
178
+ def store(response)
179
+ if response.cacheable?
180
+ trace :store
181
+ @storage.write(@request, response)
182
+ else
183
+ trace :invalid
184
+ end
185
+ end
186
+
187
+ # Internal: Fetches the response from the Faraday stack and stores it.
188
+ #
189
+ # env - the environment 'Hash' from the Faraday stack.
190
+ #
191
+ # Returns the fresh 'Faraday::Response' instance.
192
+ def fetch(env)
193
+ trace :miss
194
+ @app.call(env).on_complete do |env|
195
+ response = Response.new(env)
196
+ store(response)
197
+ end
198
+ end
199
+
200
+ # Internal: Creates a new 'Hash' containing the request information.
201
+ #
202
+ # env - the environment 'Hash' from the Faraday stack.
203
+ #
204
+ # Returns a 'Hash' containing the ':method', ':url' and 'request_headers'
205
+ # entries.
206
+ def create_request(env)
207
+ @request = env.slice(:method, :url)
208
+ @request[:request_headers] = env[:request_headers].dup
209
+ @request
210
+ end
211
+
212
+ # Internal: Logs the trace info about the incoming request
213
+ # and how the middleware handled it.
214
+ # This method does nothing if theresn't a logger present.
215
+ #
216
+ # Returns nothing.
217
+ def log_request
218
+ return unless @logger
219
+
220
+ method = @request[:method].to_s.upcase
221
+ path = @request[:url].path
222
+ line = "HTTP Cache: [#{method} #{path}] #{@trace.join(', ')}"
223
+ @logger.debug(line)
224
+ end
225
+ end
226
+ end
227
+
228
+ Faraday.register_middleware :http_cache => lambda { Faraday::HttpCache }
@@ -0,0 +1 @@
1
+ require 'faraday/http_cache'