faraday-http-cache 0.0.1.dev

Sign up to get free protection for your applications and to get access to all the features.
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'