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.
@@ -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. This class includes the Options, Config, and Core modules
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
- include Rack::Cache::Config
16
- include Rack::Cache::Core
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
- initialize_core
27
- initialize_config(&block)
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 call! method is invoked on the duplicate context instance.
31
- # process_request is defined in Core.
32
- alias_method :call!, :process_request
33
- protected :call!
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 each
36
- # request in a duplicate object, unless the +rack.run_once+ variable is set
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
- public
47
- # IO-like object that receives log, warning, and error messages;
48
- # defaults to the rack.errors environment variable.
49
- def errors
50
- @errors || (@env && (@errors = @env['rack.errors'])) || STDERR
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
- # Set the output stream for log messages, warnings, and errors.
54
- def errors=(ioish)
55
- fail "stream must respond to :write" if ! ioish.respond_to?(:write)
56
- @errors = ioish
88
+ private
89
+
90
+ # Record that an event took place.
91
+ def record(event)
92
+ @trace << event
57
93
  end
58
94
 
59
- # The configured MetaStore instance. Changing the rack-cache.metastore
60
- # environment variable effects the result of this method immediately.
61
- def metastore
62
- uri = options['rack-cache.metastore']
63
- storage.resolve_metastore_uri(uri)
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
- # The configured EntityStore instance. Changing the rack-cache.entitystore
67
- # environment variable effects the result of this method immediately.
68
- def entitystore
69
- uri = options['rack-cache.entitystore']
70
- storage.resolve_entitystore_uri(uri)
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
- protected
74
- # Write a log message to the errors stream. +level+ is a symbol
75
- # such as :error, :warn, :info, or :trace.
76
- def log(level, message=nil, *params)
77
- errors.write("[cache] #{level}: #{message}\n" % params)
78
- errors.flush
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
- def info(*message, &bk)
82
- log :info, *message, &bk
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
- def warn(*message, &bk)
86
- log :warn, *message, &bk
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
- def trace(*message, &bk)
90
- return unless verbose?
91
- log :trace, *message, &bk
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.length
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
- private :slurp
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.read(body_path(key))
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
@@ -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
- entries = read(request.fullpath)
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
- response = Rack::Cache::Response.new(res['X-Status'].to_i, res, body)
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.fullpath
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-Status'] = response.status.to_s
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
- entries.unshift [stored_env, {}.update(response.headers)]
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