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 +13 -0
- data/README.md +73 -0
- data/lib/faraday/http_cache/cache_control.rb +112 -0
- data/lib/faraday/http_cache/response.rb +170 -0
- data/lib/faraday/http_cache/storage.rb +71 -0
- data/lib/faraday/http_cache.rb +228 -0
- data/lib/faraday-http-cache.rb +1 -0
- data/spec/cache_control_spec.rb +107 -0
- data/spec/middleware_spec.rb +143 -0
- data/spec/response_spec.rb +150 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/storage_spec.rb +41 -0
- data/spec/support/test_app.rb +72 -0
- data/spec/support/test_server.rb +64 -0
- metadata +197 -0
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'
|