rtomayko-rack-cache 0.3.0 → 0.3.9
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/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
|