rtomayko-rack-cache 0.3.0 → 0.3.9
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +41 -0
- data/README +0 -1
- data/TODO +14 -10
- data/doc/configuration.markdown +7 -153
- data/doc/index.markdown +1 -3
- data/example/sinatra/app.rb +25 -0
- data/example/sinatra/views/index.erb +44 -0
- data/lib/rack/cache.rb +5 -11
- data/lib/rack/cache/cachecontrol.rb +193 -0
- data/lib/rack/cache/context.rb +188 -51
- data/lib/rack/cache/entitystore.rb +10 -4
- data/lib/rack/cache/key.rb +52 -0
- data/lib/rack/cache/metastore.rb +52 -16
- data/lib/rack/cache/options.rb +29 -13
- data/lib/rack/cache/request.rb +11 -15
- data/lib/rack/cache/response.rb +221 -30
- data/lib/rack/cache/storage.rb +1 -2
- data/rack-cache.gemspec +9 -14
- data/test/cache_test.rb +4 -1
- data/test/cachecontrol_test.rb +139 -0
- data/test/context_test.rb +198 -169
- data/test/entitystore_test.rb +12 -11
- data/test/key_test.rb +50 -0
- data/test/metastore_test.rb +57 -14
- data/test/options_test.rb +11 -0
- data/test/request_test.rb +19 -0
- data/test/response_test.rb +164 -23
- data/test/spec_setup.rb +6 -0
- metadata +13 -19
- data/lib/rack/cache/config.rb +0 -65
- data/lib/rack/cache/config/busters.rb +0 -16
- data/lib/rack/cache/config/default.rb +0 -133
- data/lib/rack/cache/config/no-cache.rb +0 -13
- data/lib/rack/cache/core.rb +0 -299
- data/lib/rack/cache/headers.rb +0 -325
- data/lib/rack/utils/environment_headers.rb +0 -78
- data/test/config_test.rb +0 -66
- data/test/core_test.rb +0 -84
- data/test/environment_headers_test.rb +0 -69
- data/test/headers_test.rb +0 -298
- data/test/logging_test.rb +0 -45
data/lib/rack/cache/context.rb
CHANGED
@@ -1,40 +1,47 @@
|
|
1
|
-
require 'rack/cache/config'
|
2
1
|
require 'rack/cache/options'
|
3
|
-
require 'rack/cache/core'
|
4
2
|
require 'rack/cache/request'
|
5
3
|
require 'rack/cache/response'
|
6
4
|
require 'rack/cache/storage'
|
7
5
|
|
8
6
|
module Rack::Cache
|
9
7
|
# Implements Rack's middleware interface and provides the context for all
|
10
|
-
# cache logic
|
11
|
-
# to provide much of its core functionality.
|
12
|
-
|
8
|
+
# cache logic, including the core logic engine.
|
13
9
|
class Context
|
14
10
|
include Rack::Cache::Options
|
15
|
-
|
16
|
-
|
11
|
+
|
12
|
+
# Array of trace Symbols
|
13
|
+
attr_reader :trace
|
17
14
|
|
18
15
|
# The Rack application object immediately downstream.
|
19
16
|
attr_reader :backend
|
20
17
|
|
21
18
|
def initialize(backend, options={}, &block)
|
22
|
-
@errors = nil
|
23
|
-
@env = nil
|
24
19
|
@backend = backend
|
20
|
+
@trace = []
|
25
21
|
initialize_options options
|
26
|
-
|
27
|
-
|
22
|
+
instance_eval(&block) if block_given?
|
23
|
+
|
24
|
+
@private_header_keys =
|
25
|
+
private_headers.map { |name| "HTTP_#{name.upcase.tr('-', '_')}" }
|
28
26
|
end
|
29
27
|
|
30
|
-
# The
|
31
|
-
#
|
32
|
-
|
33
|
-
|
28
|
+
# The configured MetaStore instance. Changing the rack-cache.metastore
|
29
|
+
# value effects the result of this method immediately.
|
30
|
+
def metastore
|
31
|
+
uri = options['rack-cache.metastore']
|
32
|
+
storage.resolve_metastore_uri(uri)
|
33
|
+
end
|
34
|
+
|
35
|
+
# The configured EntityStore instance. Changing the rack-cache.entitystore
|
36
|
+
# value effects the result of this method immediately.
|
37
|
+
def entitystore
|
38
|
+
uri = options['rack-cache.entitystore']
|
39
|
+
storage.resolve_entitystore_uri(uri)
|
40
|
+
end
|
34
41
|
|
35
|
-
# The Rack call interface. The receiver acts as a prototype and runs
|
36
|
-
# request in a
|
37
|
-
# in the environment.
|
42
|
+
# The Rack call interface. The receiver acts as a prototype and runs
|
43
|
+
# each request in a dup object unless the +rack.run_once+ variable is
|
44
|
+
# set in the environment.
|
38
45
|
def call(env)
|
39
46
|
if env['rack.run_once']
|
40
47
|
call! env
|
@@ -43,53 +50,183 @@ module Rack::Cache
|
|
43
50
|
end
|
44
51
|
end
|
45
52
|
|
46
|
-
|
47
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
@
|
53
|
+
# The real Rack call interface. The caching logic is performed within
|
54
|
+
# the context of the receiver.
|
55
|
+
def call!(env)
|
56
|
+
@trace = []
|
57
|
+
@env = @default_options.merge(env)
|
58
|
+
@request = Request.new(@env.dup.freeze)
|
59
|
+
|
60
|
+
response =
|
61
|
+
if @request.get? || @request.head?
|
62
|
+
if !@env['HTTP_EXPECT']
|
63
|
+
lookup
|
64
|
+
else
|
65
|
+
pass
|
66
|
+
end
|
67
|
+
else
|
68
|
+
invalidate
|
69
|
+
end
|
70
|
+
|
71
|
+
# log trace and set X-Rack-Cache tracing header
|
72
|
+
trace = @trace.join(', ')
|
73
|
+
response.headers['X-Rack-Cache'] = trace
|
74
|
+
|
75
|
+
# write log message to rack.errors
|
76
|
+
if verbose?
|
77
|
+
message = "cache: [%s %s] %s\n" %
|
78
|
+
[@request.request_method, @request.fullpath, trace]
|
79
|
+
@env['rack.errors'].write(message)
|
80
|
+
end
|
81
|
+
|
82
|
+
# tidy up response a bit
|
83
|
+
response.not_modified! if not_modified?(response)
|
84
|
+
response.body = [] if @request.head?
|
85
|
+
response.to_a
|
51
86
|
end
|
52
87
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
88
|
+
private
|
89
|
+
|
90
|
+
# Record that an event took place.
|
91
|
+
def record(event)
|
92
|
+
@trace << event
|
57
93
|
end
|
58
94
|
|
59
|
-
#
|
60
|
-
#
|
61
|
-
|
62
|
-
|
63
|
-
|
95
|
+
# Does the request include authorization or other sensitive information
|
96
|
+
# that should cause the response to be considered private by default?
|
97
|
+
# Private responses are not stored in the cache.
|
98
|
+
def private_request?
|
99
|
+
@private_header_keys.any? { |key| @env.key?(key) }
|
64
100
|
end
|
65
101
|
|
66
|
-
#
|
67
|
-
#
|
68
|
-
def
|
69
|
-
|
70
|
-
|
102
|
+
# Determine if the #response validators (ETag, Last-Modified) matches
|
103
|
+
# a conditional value specified in #request.
|
104
|
+
def not_modified?(response)
|
105
|
+
response.etag_matches?(@request.env['HTTP_IF_NONE_MATCH']) ||
|
106
|
+
response.last_modified_at?(@request.env['HTTP_IF_MODIFIED_SINCE'])
|
71
107
|
end
|
72
108
|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
109
|
+
# Whether the cache entry is "fresh enough" to satisfy the request.
|
110
|
+
def fresh_enough?(entry)
|
111
|
+
if entry.fresh?
|
112
|
+
if max_age = @request.cache_control.max_age
|
113
|
+
max_age > 0 && max_age >= entry.age
|
114
|
+
else
|
115
|
+
true
|
116
|
+
end
|
117
|
+
end
|
79
118
|
end
|
80
119
|
|
81
|
-
|
82
|
-
|
120
|
+
# Delegate the request to the backend and create the response.
|
121
|
+
def forward
|
122
|
+
Response.new(*backend.call(@env))
|
83
123
|
end
|
84
124
|
|
85
|
-
|
86
|
-
|
125
|
+
# The request is sent to the backend, and the backend's response is sent
|
126
|
+
# to the client, but is not entered into the cache.
|
127
|
+
def pass
|
128
|
+
record :pass
|
129
|
+
forward
|
87
130
|
end
|
88
131
|
|
89
|
-
|
90
|
-
|
91
|
-
|
132
|
+
# Invalidate POST, PUT, DELETE and all methods not understood by this cache
|
133
|
+
# See RFC2616 13.10
|
134
|
+
def invalidate
|
135
|
+
record :invalidate
|
136
|
+
metastore.invalidate(@request, entitystore)
|
137
|
+
pass
|
92
138
|
end
|
93
|
-
end
|
94
139
|
|
140
|
+
# Try to serve the response from cache. When a matching cache entry is
|
141
|
+
# found and is fresh, use it as the response without forwarding any
|
142
|
+
# request to the backend. When a matching cache entry is found but is
|
143
|
+
# stale, attempt to #validate the entry with the backend using conditional
|
144
|
+
# GET. When no matching cache entry is found, trigger #miss processing.
|
145
|
+
def lookup
|
146
|
+
if @request.no_cache?
|
147
|
+
record :reload
|
148
|
+
fetch
|
149
|
+
elsif entry = metastore.lookup(@request, entitystore)
|
150
|
+
if fresh_enough?(entry)
|
151
|
+
record :fresh
|
152
|
+
entry.headers['Age'] = entry.age.to_s
|
153
|
+
entry
|
154
|
+
else
|
155
|
+
record :stale
|
156
|
+
validate(entry)
|
157
|
+
end
|
158
|
+
else
|
159
|
+
record :miss
|
160
|
+
fetch
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Validate that the cache entry is fresh. The original request is used
|
165
|
+
# as a template for a conditional GET request with the backend.
|
166
|
+
def validate(entry)
|
167
|
+
# send no head requests because we want content
|
168
|
+
@env['REQUEST_METHOD'] = 'GET'
|
169
|
+
|
170
|
+
# add our cached validators to the environment
|
171
|
+
@env['HTTP_IF_MODIFIED_SINCE'] = entry.last_modified
|
172
|
+
@env['HTTP_IF_NONE_MATCH'] = entry.etag
|
173
|
+
|
174
|
+
backend_response = forward
|
175
|
+
|
176
|
+
response =
|
177
|
+
if backend_response.status == 304
|
178
|
+
record :valid
|
179
|
+
entry = entry.dup
|
180
|
+
entry.headers.delete('Date')
|
181
|
+
%w[Date Expires Cache-Control ETag Last-Modified].each do |name|
|
182
|
+
next unless value = backend_response.headers[name]
|
183
|
+
entry.headers[name] = value
|
184
|
+
end
|
185
|
+
entry
|
186
|
+
else
|
187
|
+
record :invalid
|
188
|
+
backend_response
|
189
|
+
end
|
190
|
+
|
191
|
+
store(response) if response.cacheable?
|
192
|
+
|
193
|
+
response
|
194
|
+
end
|
195
|
+
|
196
|
+
# The cache missed or a reload is required. Forward the request to the
|
197
|
+
# backend and determine whether the response should be stored.
|
198
|
+
def fetch
|
199
|
+
# send no head requests because we want content
|
200
|
+
@env['REQUEST_METHOD'] = 'GET'
|
201
|
+
|
202
|
+
# avoid that the backend sends no content
|
203
|
+
@env.delete('HTTP_IF_MODIFIED_SINCE')
|
204
|
+
@env.delete('HTTP_IF_NONE_MATCH')
|
205
|
+
|
206
|
+
response = forward
|
207
|
+
|
208
|
+
# Mark the response as explicitly private if any of the private
|
209
|
+
# request headers are present and the response was not explicitly
|
210
|
+
# declared public.
|
211
|
+
if private_request? && !response.cache_control.public?
|
212
|
+
response.private = true
|
213
|
+
elsif default_ttl > 0 && response.ttl.nil? && !response.cache_control.must_revalidate?
|
214
|
+
# assign a default TTL for the cache entry if none was specified in
|
215
|
+
# the response; the must-revalidate cache control directive disables
|
216
|
+
# default ttl assigment.
|
217
|
+
response.ttl = default_ttl
|
218
|
+
end
|
219
|
+
|
220
|
+
store(response) if response.cacheable?
|
221
|
+
|
222
|
+
response
|
223
|
+
end
|
224
|
+
|
225
|
+
# Write the response to the cache.
|
226
|
+
def store(response)
|
227
|
+
record :store
|
228
|
+
metastore.store(@request, response, entitystore)
|
229
|
+
response.headers['Age'] = response.age.to_s
|
230
|
+
end
|
231
|
+
end
|
95
232
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'digest/sha1'
|
2
2
|
|
3
3
|
module Rack::Cache
|
4
|
+
|
4
5
|
# Entity stores are used to cache response bodies across requests. All
|
5
6
|
# Implementations are required to calculate a SHA checksum of the data written
|
6
7
|
# which becomes the response body's key.
|
@@ -13,7 +14,7 @@ module Rack::Cache
|
|
13
14
|
def slurp(body)
|
14
15
|
digest, size = Digest::SHA1.new, 0
|
15
16
|
body.each do |part|
|
16
|
-
size += part
|
17
|
+
size += bytesize(part)
|
17
18
|
digest << part
|
18
19
|
yield part
|
19
20
|
end
|
@@ -21,7 +22,13 @@ module Rack::Cache
|
|
21
22
|
[ digest.hexdigest, size ]
|
22
23
|
end
|
23
24
|
|
24
|
-
|
25
|
+
if ''.respond_to?(:bytesize)
|
26
|
+
def bytesize(string); string.bytesize; end
|
27
|
+
else
|
28
|
+
def bytesize(string); string.size; end
|
29
|
+
end
|
30
|
+
|
31
|
+
private :slurp, :bytesize
|
25
32
|
|
26
33
|
|
27
34
|
# Stores entity bodies on the heap using a Hash object.
|
@@ -90,7 +97,7 @@ module Rack::Cache
|
|
90
97
|
end
|
91
98
|
|
92
99
|
def read(key)
|
93
|
-
File.
|
100
|
+
File.open(body_path(key), 'rb') { |f| f.read }
|
94
101
|
rescue Errno::ENOENT
|
95
102
|
nil
|
96
103
|
end
|
@@ -241,7 +248,6 @@ module Rack::Cache
|
|
241
248
|
|
242
249
|
MEMCACHE = MemCache
|
243
250
|
MEMCACHED = MemCache
|
244
|
-
|
245
251
|
end
|
246
252
|
|
247
253
|
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'rack/utils'
|
2
|
+
|
3
|
+
module Rack::Cache
|
4
|
+
class Key
|
5
|
+
include Rack::Utils
|
6
|
+
|
7
|
+
# Implement .call, since it seems like the "Rack-y" thing to do. Plus, it
|
8
|
+
# opens the door for cache key generators to just be blocks.
|
9
|
+
def self.call(request)
|
10
|
+
new(request).generate
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(request)
|
14
|
+
@request = request
|
15
|
+
end
|
16
|
+
|
17
|
+
# Generate a normalized cache key for the request.
|
18
|
+
def generate
|
19
|
+
parts = []
|
20
|
+
parts << @request.scheme << "://"
|
21
|
+
parts << @request.host
|
22
|
+
|
23
|
+
if @request.scheme == "https" && @request.port != 443 ||
|
24
|
+
@request.scheme == "http" && @request.port != 80
|
25
|
+
parts << ":" << @request.port.to_s
|
26
|
+
end
|
27
|
+
|
28
|
+
parts << @request.script_name
|
29
|
+
parts << @request.path_info
|
30
|
+
|
31
|
+
if qs = query_string
|
32
|
+
parts << "?"
|
33
|
+
parts << qs
|
34
|
+
end
|
35
|
+
|
36
|
+
parts.join
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
# Build a normalized query string by alphabetizing all keys/values
|
41
|
+
# and applying consistent escaping.
|
42
|
+
def query_string
|
43
|
+
return nil if @request.query_string.nil?
|
44
|
+
|
45
|
+
@request.query_string.split(/[&;] */n).
|
46
|
+
map { |p| unescape(p).split('=', 2) }.
|
47
|
+
sort.
|
48
|
+
map { |k,v| "#{escape(k)}=#{escape(v)}" }.
|
49
|
+
join('&')
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/rack/cache/metastore.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
require 'rack'
|
2
1
|
require 'fileutils'
|
3
2
|
require 'digest/sha1'
|
3
|
+
require 'rack/utils'
|
4
4
|
|
5
5
|
module Rack::Cache
|
6
6
|
|
@@ -25,7 +25,8 @@ module Rack::Cache
|
|
25
25
|
# Rack::Cache::Response object if the cache hits or nil if no cache entry
|
26
26
|
# was found.
|
27
27
|
def lookup(request, entity_store)
|
28
|
-
|
28
|
+
key = cache_key(request)
|
29
|
+
entries = read(key)
|
29
30
|
|
30
31
|
# bail out if we have nothing cached
|
31
32
|
return nil if entries.empty?
|
@@ -37,9 +38,7 @@ module Rack::Cache
|
|
37
38
|
|
38
39
|
req, res = match
|
39
40
|
if body = entity_store.open(res['X-Content-Digest'])
|
40
|
-
|
41
|
-
response.activate!
|
42
|
-
response
|
41
|
+
restore_response(res, body)
|
43
42
|
else
|
44
43
|
# TODO the metastore referenced an entity that doesn't exist in
|
45
44
|
# the entitystore. we definitely want to return nil but we should
|
@@ -50,21 +49,17 @@ module Rack::Cache
|
|
50
49
|
# Write a cache entry to the store under the given key. Existing
|
51
50
|
# entries are read and any that match the response are removed.
|
52
51
|
# This method calls #write with the new list of cache entries.
|
53
|
-
#--
|
54
|
-
# TODO canonicalize URL key
|
55
52
|
def store(request, response, entity_store)
|
56
|
-
key = request
|
53
|
+
key = cache_key(request)
|
57
54
|
stored_env = persist_request(request)
|
58
55
|
|
59
56
|
# write the response body to the entity store if this is the
|
60
57
|
# original response.
|
61
|
-
response['X-
|
62
|
-
if response['X-Content-Digest'].nil?
|
58
|
+
if response.headers['X-Content-Digest'].nil?
|
63
59
|
digest, size = entity_store.write(response.body)
|
64
|
-
response['X-Content-Digest'] = digest
|
65
|
-
response['Content-Length'] = size.to_s unless response['Transfer-Encoding']
|
60
|
+
response.headers['X-Content-Digest'] = digest
|
61
|
+
response.headers['Content-Length'] = size.to_s unless response.headers['Transfer-Encoding']
|
66
62
|
response.body = entity_store.open(digest)
|
67
|
-
response.activate!
|
68
63
|
end
|
69
64
|
|
70
65
|
# read existing cache entries, remove non-varying, and add this one to
|
@@ -75,11 +70,41 @@ module Rack::Cache
|
|
75
70
|
(vary == res['Vary']) &&
|
76
71
|
requests_match?(vary, env, stored_env)
|
77
72
|
end
|
78
|
-
|
73
|
+
|
74
|
+
headers = persist_response(response)
|
75
|
+
headers.delete 'Age'
|
76
|
+
|
77
|
+
entries.unshift [stored_env, headers]
|
79
78
|
write key, entries
|
79
|
+
key
|
80
|
+
end
|
81
|
+
|
82
|
+
# Generate a cache key for the request.
|
83
|
+
def cache_key(request)
|
84
|
+
keygen = request.env['rack-cache.cache_key'] || Key
|
85
|
+
keygen.call(request)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Invalidate all cache entries that match the request.
|
89
|
+
def invalidate(request, entity_store)
|
90
|
+
modified = false
|
91
|
+
key = cache_key(request)
|
92
|
+
entries =
|
93
|
+
read(key).map do |req, res|
|
94
|
+
response = restore_response(res)
|
95
|
+
if response.fresh?
|
96
|
+
response.expire!
|
97
|
+
modified = true
|
98
|
+
[req, persist_response(response)]
|
99
|
+
else
|
100
|
+
[req, res]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
write key, entries if modified
|
80
104
|
end
|
81
105
|
|
82
106
|
private
|
107
|
+
|
83
108
|
# Extract the environment Hash from +request+ while making any
|
84
109
|
# necessary modifications in preparation for persistence. The Hash
|
85
110
|
# returned must be marshalable.
|
@@ -89,6 +114,19 @@ module Rack::Cache
|
|
89
114
|
env
|
90
115
|
end
|
91
116
|
|
117
|
+
# Converts a stored response hash into a Response object. The caller
|
118
|
+
# is responsible for loading and passing the body if needed.
|
119
|
+
def restore_response(hash, body=nil)
|
120
|
+
status = hash.delete('X-Status').to_i
|
121
|
+
Rack::Cache::Response.new(status, hash, body)
|
122
|
+
end
|
123
|
+
|
124
|
+
def persist_response(response)
|
125
|
+
hash = response.headers.to_hash
|
126
|
+
hash['X-Status'] = response.status.to_s
|
127
|
+
hash
|
128
|
+
end
|
129
|
+
|
92
130
|
# Determine whether the two environment hashes are non-varying based on
|
93
131
|
# the vary response header value provided.
|
94
132
|
def requests_match?(vary, env1, env2)
|
@@ -122,7 +160,6 @@ module Rack::Cache
|
|
122
160
|
end
|
123
161
|
|
124
162
|
private
|
125
|
-
|
126
163
|
# Generate a SHA1 hex digest for the specified string. This is a
|
127
164
|
# simple utility method for meta store implementations.
|
128
165
|
def hexdigest(data)
|
@@ -130,7 +167,6 @@ module Rack::Cache
|
|
130
167
|
end
|
131
168
|
|
132
169
|
public
|
133
|
-
|
134
170
|
# Concrete MetaStore implementation that uses a simple Hash to store
|
135
171
|
# request/response pairs on the heap.
|
136
172
|
class Heap < MetaStore
|