rack-cache 0.3.0 → 0.4

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of rack-cache might be problematic. Click here for more details.

Files changed (44) hide show
  1. data/CHANGES +43 -0
  2. data/README +18 -9
  3. data/Rakefile +1 -14
  4. data/TODO +13 -14
  5. data/doc/configuration.markdown +7 -153
  6. data/doc/faq.markdown +8 -0
  7. data/doc/index.markdown +7 -9
  8. data/example/sinatra/app.rb +25 -0
  9. data/example/sinatra/views/index.erb +44 -0
  10. data/lib/rack/cache.rb +5 -11
  11. data/lib/rack/cache/cachecontrol.rb +193 -0
  12. data/lib/rack/cache/context.rb +190 -52
  13. data/lib/rack/cache/entitystore.rb +10 -4
  14. data/lib/rack/cache/key.rb +52 -0
  15. data/lib/rack/cache/metastore.rb +52 -16
  16. data/lib/rack/cache/options.rb +60 -39
  17. data/lib/rack/cache/request.rb +11 -15
  18. data/lib/rack/cache/response.rb +221 -30
  19. data/lib/rack/cache/storage.rb +1 -2
  20. data/rack-cache.gemspec +9 -15
  21. data/test/cache_test.rb +9 -6
  22. data/test/cachecontrol_test.rb +139 -0
  23. data/test/context_test.rb +251 -169
  24. data/test/entitystore_test.rb +12 -11
  25. data/test/key_test.rb +50 -0
  26. data/test/metastore_test.rb +57 -14
  27. data/test/options_test.rb +11 -0
  28. data/test/request_test.rb +19 -0
  29. data/test/response_test.rb +164 -23
  30. data/test/spec_setup.rb +7 -0
  31. metadata +12 -20
  32. data/doc/events.dot +0 -27
  33. data/lib/rack/cache/config.rb +0 -65
  34. data/lib/rack/cache/config/busters.rb +0 -16
  35. data/lib/rack/cache/config/default.rb +0 -133
  36. data/lib/rack/cache/config/no-cache.rb +0 -13
  37. data/lib/rack/cache/core.rb +0 -299
  38. data/lib/rack/cache/headers.rb +0 -325
  39. data/lib/rack/utils/environment_headers.rb +0 -78
  40. data/test/config_test.rb +0 -66
  41. data/test/core_test.rb +0 -84
  42. data/test/environment_headers_test.rb +0 -69
  43. data/test/headers_test.rb +0 -298
  44. data/test/logging_test.rb +0 -45
data/lib/rack/cache.rb CHANGED
@@ -1,10 +1,5 @@
1
- require 'fileutils'
2
- require 'time'
3
1
  require 'rack'
4
2
 
5
- module Rack #:nodoc:
6
- end
7
-
8
3
  # = HTTP Caching For Rack
9
4
  #
10
5
  # Rack::Cache is suitable as a quick, drop-in component to enable HTTP caching
@@ -15,7 +10,6 @@ end
15
10
  # * Freshness/expiration based caching and validation
16
11
  # * Supports HTTP Vary
17
12
  # * Portable: 100% Ruby / works with any Rack-enabled framework
18
- # * VCL-like configuration language for advanced caching policies
19
13
  # * Disk, memcached, and heap memory storage backends
20
14
  #
21
15
  # === Usage
@@ -32,12 +26,12 @@ end
32
26
  # set :entitystore, 'file:/var/cache/rack'
33
27
  # end
34
28
  # run app
35
- #
36
29
  module Rack::Cache
37
- require 'rack/cache/request'
38
- require 'rack/cache/response'
39
- require 'rack/cache/context'
40
- require 'rack/cache/storage'
30
+ autoload :Request, 'rack/cache/request'
31
+ autoload :Response, 'rack/cache/response'
32
+ autoload :Context, 'rack/cache/context'
33
+ autoload :Storage, 'rack/cache/storage'
34
+ autoload :CacheControl, 'rack/cache/cachecontrol'
41
35
 
42
36
  # Create a new Rack::Cache middleware component that fetches resources from
43
37
  # the specified backend application. The +options+ Hash can be used to
@@ -0,0 +1,193 @@
1
+ module Rack
2
+ module Cache
3
+
4
+ # Parses a Cache-Control header and exposes the directives as a Hash.
5
+ # Directives that do not have values are set to +true+.
6
+ class CacheControl < Hash
7
+ def initialize(value=nil)
8
+ parse(value)
9
+ end
10
+
11
+ # Indicates that the response MAY be cached by any cache, even if it
12
+ # would normally be non-cacheable or cacheable only within a non-
13
+ # shared cache.
14
+ #
15
+ # A response may be considered public without this directive if the
16
+ # private directive is not set and the request does not include an
17
+ # Authorization header.
18
+ def public?
19
+ self['public']
20
+ end
21
+
22
+ # Indicates that all or part of the response message is intended for
23
+ # a single user and MUST NOT be cached by a shared cache. This
24
+ # allows an origin server to state that the specified parts of the
25
+ # response are intended for only one user and are not a valid
26
+ # response for requests by other users. A private (non-shared) cache
27
+ # MAY cache the response.
28
+ #
29
+ # Note: This usage of the word private only controls where the
30
+ # response may be cached, and cannot ensure the privacy of the
31
+ # message content.
32
+ def private?
33
+ self['private']
34
+ end
35
+
36
+ # When set in a response, a cache MUST NOT use the response to satisfy a
37
+ # subsequent request without successful revalidation with the origin
38
+ # server. This allows an origin server to prevent caching even by caches
39
+ # that have been configured to return stale responses to client requests.
40
+ #
41
+ # Note that this does not necessary imply that the response may not be
42
+ # stored by the cache, only that the cache cannot serve it without first
43
+ # making a conditional GET request with the origin server.
44
+ #
45
+ # When set in a request, the server MUST NOT use a cached copy for its
46
+ # response. This has quite different semantics compared to the no-cache
47
+ # directive on responses. When the client specifies no-cache, it causes
48
+ # an end-to-end reload, forcing each cache to update their cached copies.
49
+ def no_cache?
50
+ self['no-cache']
51
+ end
52
+
53
+ # Indicates that the response MUST NOT be stored under any circumstances.
54
+ #
55
+ # The purpose of the no-store directive is to prevent the
56
+ # inadvertent release or retention of sensitive information (for
57
+ # example, on backup tapes). The no-store directive applies to the
58
+ # entire message, and MAY be sent either in a response or in a
59
+ # request. If sent in a request, a cache MUST NOT store any part of
60
+ # either this request or any response to it. If sent in a response,
61
+ # a cache MUST NOT store any part of either this response or the
62
+ # request that elicited it. This directive applies to both non-
63
+ # shared and shared caches. "MUST NOT store" in this context means
64
+ # that the cache MUST NOT intentionally store the information in
65
+ # non-volatile storage, and MUST make a best-effort attempt to
66
+ # remove the information from volatile storage as promptly as
67
+ # possible after forwarding it.
68
+ #
69
+ # The purpose of this directive is to meet the stated requirements
70
+ # of certain users and service authors who are concerned about
71
+ # accidental releases of information via unanticipated accesses to
72
+ # cache data structures. While the use of this directive might
73
+ # improve privacy in some cases, we caution that it is NOT in any
74
+ # way a reliable or sufficient mechanism for ensuring privacy. In
75
+ # particular, malicious or compromised caches might not recognize or
76
+ # obey this directive, and communications networks might be
77
+ # vulnerable to eavesdropping.
78
+ def no_store?
79
+ self['no-store']
80
+ end
81
+
82
+ # The expiration time of an entity MAY be specified by the origin
83
+ # server using the Expires header (see section 14.21). Alternatively,
84
+ # it MAY be specified using the max-age directive in a response. When
85
+ # the max-age cache-control directive is present in a cached response,
86
+ # the response is stale if its current age is greater than the age
87
+ # value given (in seconds) at the time of a new request for that
88
+ # resource. The max-age directive on a response implies that the
89
+ # response is cacheable (i.e., "public") unless some other, more
90
+ # restrictive cache directive is also present.
91
+ #
92
+ # If a response includes both an Expires header and a max-age
93
+ # directive, the max-age directive overrides the Expires header, even
94
+ # if the Expires header is more restrictive. This rule allows an origin
95
+ # server to provide, for a given response, a longer expiration time to
96
+ # an HTTP/1.1 (or later) cache than to an HTTP/1.0 cache. This might be
97
+ # useful if certain HTTP/1.0 caches improperly calculate ages or
98
+ # expiration times, perhaps due to desynchronized clocks.
99
+ #
100
+ # Many HTTP/1.0 cache implementations will treat an Expires value that
101
+ # is less than or equal to the response Date value as being equivalent
102
+ # to the Cache-Control response directive "no-cache". If an HTTP/1.1
103
+ # cache receives such a response, and the response does not include a
104
+ # Cache-Control header field, it SHOULD consider the response to be
105
+ # non-cacheable in order to retain compatibility with HTTP/1.0 servers.
106
+ #
107
+ # When the max-age directive is included in the request, it indicates
108
+ # that the client is willing to accept a response whose age is no
109
+ # greater than the specified time in seconds.
110
+ def max_age
111
+ self['max-age'].to_i if key?('max-age')
112
+ end
113
+
114
+ # If a response includes an s-maxage directive, then for a shared
115
+ # cache (but not for a private cache), the maximum age specified by
116
+ # this directive overrides the maximum age specified by either the
117
+ # max-age directive or the Expires header. The s-maxage directive
118
+ # also implies the semantics of the proxy-revalidate directive. i.e.,
119
+ # that the shared cache must not use the entry after it becomes stale
120
+ # to respond to a subsequent request without first revalidating it with
121
+ # the origin server. The s-maxage directive is always ignored by a
122
+ # private cache.
123
+ def shared_max_age
124
+ self['s-maxage'].to_i if key?('s-maxage')
125
+ end
126
+ alias_method :s_maxage, :shared_max_age
127
+
128
+ # Because a cache MAY be configured to ignore a server's specified
129
+ # expiration time, and because a client request MAY include a max-
130
+ # stale directive (which has a similar effect), the protocol also
131
+ # includes a mechanism for the origin server to require revalidation
132
+ # of a cache entry on any subsequent use. When the must-revalidate
133
+ # directive is present in a response received by a cache, that cache
134
+ # MUST NOT use the entry after it becomes stale to respond to a
135
+ # subsequent request without first revalidating it with the origin
136
+ # server. (I.e., the cache MUST do an end-to-end revalidation every
137
+ # time, if, based solely on the origin server's Expires or max-age
138
+ # value, the cached response is stale.)
139
+ #
140
+ # The must-revalidate directive is necessary to support reliable
141
+ # operation for certain protocol features. In all circumstances an
142
+ # HTTP/1.1 cache MUST obey the must-revalidate directive; in
143
+ # particular, if the cache cannot reach the origin server for any
144
+ # reason, it MUST generate a 504 (Gateway Timeout) response.
145
+ #
146
+ # Servers SHOULD send the must-revalidate directive if and only if
147
+ # failure to revalidate a request on the entity could result in
148
+ # incorrect operation, such as a silently unexecuted financial
149
+ # transaction. Recipients MUST NOT take any automated action that
150
+ # violates this directive, and MUST NOT automatically provide an
151
+ # unvalidated copy of the entity if revalidation fails.
152
+ def must_revalidate?
153
+ self['must-revalidate']
154
+ end
155
+
156
+ # The proxy-revalidate directive has the same meaning as the must-
157
+ # revalidate directive, except that it does not apply to non-shared
158
+ # user agent caches. It can be used on a response to an
159
+ # authenticated request to permit the user's cache to store and
160
+ # later return the response without needing to revalidate it (since
161
+ # it has already been authenticated once by that user), while still
162
+ # requiring proxies that service many users to revalidate each time
163
+ # (in order to make sure that each user has been authenticated).
164
+ # Note that such authenticated responses also need the public cache
165
+ # control directive in order to allow them to be cached at all.
166
+ def proxy_revalidate?
167
+ self['proxy-revalidate']
168
+ end
169
+
170
+ def to_s
171
+ bools, vals = [], []
172
+ each do |key,value|
173
+ if value == true
174
+ bools << key
175
+ elsif value
176
+ vals << "#{key}=#{value}"
177
+ end
178
+ end
179
+ (bools.sort + vals.sort).join(', ')
180
+ end
181
+
182
+ private
183
+ def parse(value)
184
+ return if value.nil? || value.empty?
185
+ value.delete(' ').split(',').inject(self) do |hash,part|
186
+ name, value = part.split('=', 2)
187
+ hash[name.downcase] = (value || true) unless name.empty?
188
+ hash
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -1,40 +1,48 @@
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
- def initialize(backend, options={}, &block)
22
- @errors = nil
23
- @env = nil
18
+ def initialize(backend, options={})
24
19
  @backend = backend
20
+ @trace = []
21
+
25
22
  initialize_options options
26
- initialize_core
27
- initialize_config(&block)
23
+ yield self if block_given?
24
+
25
+ @private_header_keys =
26
+ private_headers.map { |name| "HTTP_#{name.upcase.tr('-', '_')}" }
28
27
  end
29
28
 
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!
29
+ # The configured MetaStore instance. Changing the rack-cache.metastore
30
+ # value effects the result of this method immediately.
31
+ def metastore
32
+ uri = options['rack-cache.metastore']
33
+ storage.resolve_metastore_uri(uri)
34
+ end
35
+
36
+ # The configured EntityStore instance. Changing the rack-cache.entitystore
37
+ # value effects the result of this method immediately.
38
+ def entitystore
39
+ uri = options['rack-cache.entitystore']
40
+ storage.resolve_entitystore_uri(uri)
41
+ end
34
42
 
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.
43
+ # The Rack call interface. The receiver acts as a prototype and runs
44
+ # each request in a dup object unless the +rack.run_once+ variable is
45
+ # set in the environment.
38
46
  def call(env)
39
47
  if env['rack.run_once']
40
48
  call! env
@@ -43,53 +51,183 @@ module Rack::Cache
43
51
  end
44
52
  end
45
53
 
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
54
+ # The real Rack call interface. The caching logic is performed within
55
+ # the context of the receiver.
56
+ def call!(env)
57
+ @trace = []
58
+ @env = @default_options.merge(env)
59
+ @request = Request.new(@env.dup.freeze)
60
+
61
+ response =
62
+ if @request.get? || @request.head?
63
+ if !@env['HTTP_EXPECT']
64
+ lookup
65
+ else
66
+ pass
67
+ end
68
+ else
69
+ invalidate
70
+ end
71
+
72
+ # log trace and set X-Rack-Cache tracing header
73
+ trace = @trace.join(', ')
74
+ response.headers['X-Rack-Cache'] = trace
75
+
76
+ # write log message to rack.errors
77
+ if verbose?
78
+ message = "cache: [%s %s] %s\n" %
79
+ [@request.request_method, @request.fullpath, trace]
80
+ @env['rack.errors'].write(message)
81
+ end
82
+
83
+ # tidy up response a bit
84
+ response.not_modified! if not_modified?(response)
85
+ response.body = [] if @request.head?
86
+ response.to_a
51
87
  end
52
88
 
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
89
+ private
90
+
91
+ # Record that an event took place.
92
+ def record(event)
93
+ @trace << event
57
94
  end
58
95
 
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)
96
+ # Does the request include authorization or other sensitive information
97
+ # that should cause the response to be considered private by default?
98
+ # Private responses are not stored in the cache.
99
+ def private_request?
100
+ @private_header_keys.any? { |key| @env.key?(key) }
64
101
  end
65
102
 
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)
103
+ # Determine if the #response validators (ETag, Last-Modified) matches
104
+ # a conditional value specified in #request.
105
+ def not_modified?(response)
106
+ response.etag_matches?(@request.env['HTTP_IF_NONE_MATCH']) ||
107
+ response.last_modified_at?(@request.env['HTTP_IF_MODIFIED_SINCE'])
71
108
  end
72
109
 
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
110
+ # Whether the cache entry is "fresh enough" to satisfy the request.
111
+ def fresh_enough?(entry)
112
+ if entry.fresh?
113
+ if allow_revalidate? && max_age = @request.cache_control.max_age
114
+ max_age > 0 && max_age >= entry.age
115
+ else
116
+ true
117
+ end
118
+ end
79
119
  end
80
120
 
81
- def info(*message, &bk)
82
- log :info, *message, &bk
121
+ # Delegate the request to the backend and create the response.
122
+ def forward
123
+ Response.new(*backend.call(@env))
83
124
  end
84
125
 
85
- def warn(*message, &bk)
86
- log :warn, *message, &bk
126
+ # The request is sent to the backend, and the backend's response is sent
127
+ # to the client, but is not entered into the cache.
128
+ def pass
129
+ record :pass
130
+ forward
87
131
  end
88
132
 
89
- def trace(*message, &bk)
90
- return unless verbose?
91
- log :trace, *message, &bk
133
+ # Invalidate POST, PUT, DELETE and all methods not understood by this cache
134
+ # See RFC2616 13.10
135
+ def invalidate
136
+ record :invalidate
137
+ metastore.invalidate(@request, entitystore)
138
+ pass
92
139
  end
93
- end
94
140
 
141
+ # Try to serve the response from cache. When a matching cache entry is
142
+ # found and is fresh, use it as the response without forwarding any
143
+ # request to the backend. When a matching cache entry is found but is
144
+ # stale, attempt to #validate the entry with the backend using conditional
145
+ # GET. When no matching cache entry is found, trigger #miss processing.
146
+ def lookup
147
+ if @request.no_cache? && allow_reload?
148
+ record :reload
149
+ fetch
150
+ elsif entry = metastore.lookup(@request, entitystore)
151
+ if fresh_enough?(entry)
152
+ record :fresh
153
+ entry.headers['Age'] = entry.age.to_s
154
+ entry
155
+ else
156
+ record :stale
157
+ validate(entry)
158
+ end
159
+ else
160
+ record :miss
161
+ fetch
162
+ end
163
+ end
164
+
165
+ # Validate that the cache entry is fresh. The original request is used
166
+ # as a template for a conditional GET request with the backend.
167
+ def validate(entry)
168
+ # send no head requests because we want content
169
+ @env['REQUEST_METHOD'] = 'GET'
170
+
171
+ # add our cached validators to the environment
172
+ @env['HTTP_IF_MODIFIED_SINCE'] = entry.last_modified
173
+ @env['HTTP_IF_NONE_MATCH'] = entry.etag
174
+
175
+ backend_response = forward
176
+
177
+ response =
178
+ if backend_response.status == 304
179
+ record :valid
180
+ entry = entry.dup
181
+ entry.headers.delete('Date')
182
+ %w[Date Expires Cache-Control ETag Last-Modified].each do |name|
183
+ next unless value = backend_response.headers[name]
184
+ entry.headers[name] = value
185
+ end
186
+ entry
187
+ else
188
+ record :invalid
189
+ backend_response
190
+ end
191
+
192
+ store(response) if response.cacheable?
193
+
194
+ response
195
+ end
196
+
197
+ # The cache missed or a reload is required. Forward the request to the
198
+ # backend and determine whether the response should be stored.
199
+ def fetch
200
+ # send no head requests because we want content
201
+ @env['REQUEST_METHOD'] = 'GET'
202
+
203
+ # avoid that the backend sends no content
204
+ @env.delete('HTTP_IF_MODIFIED_SINCE')
205
+ @env.delete('HTTP_IF_NONE_MATCH')
206
+
207
+ response = forward
208
+
209
+ # Mark the response as explicitly private if any of the private
210
+ # request headers are present and the response was not explicitly
211
+ # declared public.
212
+ if private_request? && !response.cache_control.public?
213
+ response.private = true
214
+ elsif default_ttl > 0 && response.ttl.nil? && !response.cache_control.must_revalidate?
215
+ # assign a default TTL for the cache entry if none was specified in
216
+ # the response; the must-revalidate cache control directive disables
217
+ # default ttl assigment.
218
+ response.ttl = default_ttl
219
+ end
220
+
221
+ store(response) if response.cacheable?
222
+
223
+ response
224
+ end
225
+
226
+ # Write the response to the cache.
227
+ def store(response)
228
+ record :store
229
+ metastore.store(@request, response, entitystore)
230
+ response.headers['Age'] = response.age.to_s
231
+ end
232
+ end
95
233
  end