rack-cache 0.2.0

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 +27 -0
  2. data/COPYING +18 -0
  3. data/README +96 -0
  4. data/Rakefile +144 -0
  5. data/TODO +40 -0
  6. data/doc/configuration.markdown +224 -0
  7. data/doc/events.dot +27 -0
  8. data/doc/faq.markdown +133 -0
  9. data/doc/index.markdown +113 -0
  10. data/doc/layout.html.erb +33 -0
  11. data/doc/license.markdown +24 -0
  12. data/doc/rack-cache.css +362 -0
  13. data/doc/storage.markdown +162 -0
  14. data/lib/rack/cache.rb +51 -0
  15. data/lib/rack/cache/config.rb +65 -0
  16. data/lib/rack/cache/config/busters.rb +16 -0
  17. data/lib/rack/cache/config/default.rb +134 -0
  18. data/lib/rack/cache/config/no-cache.rb +13 -0
  19. data/lib/rack/cache/context.rb +95 -0
  20. data/lib/rack/cache/core.rb +271 -0
  21. data/lib/rack/cache/entitystore.rb +224 -0
  22. data/lib/rack/cache/headers.rb +237 -0
  23. data/lib/rack/cache/metastore.rb +309 -0
  24. data/lib/rack/cache/options.rb +119 -0
  25. data/lib/rack/cache/request.rb +37 -0
  26. data/lib/rack/cache/response.rb +76 -0
  27. data/lib/rack/cache/storage.rb +50 -0
  28. data/lib/rack/utils/environment_headers.rb +78 -0
  29. data/rack-cache.gemspec +74 -0
  30. data/test/cache_test.rb +35 -0
  31. data/test/config_test.rb +66 -0
  32. data/test/context_test.rb +465 -0
  33. data/test/core_test.rb +84 -0
  34. data/test/entitystore_test.rb +176 -0
  35. data/test/environment_headers_test.rb +71 -0
  36. data/test/headers_test.rb +215 -0
  37. data/test/logging_test.rb +45 -0
  38. data/test/metastore_test.rb +210 -0
  39. data/test/options_test.rb +64 -0
  40. data/test/pony.jpg +0 -0
  41. data/test/response_test.rb +37 -0
  42. data/test/spec_setup.rb +189 -0
  43. data/test/storage_test.rb +94 -0
  44. metadata +120 -0
@@ -0,0 +1,66 @@
1
+ require "#{File.dirname(__FILE__)}/spec_setup"
2
+ require 'rack/cache/config'
3
+
4
+ class MockConfig
5
+ include Rack::Cache::Config
6
+ def configured!
7
+ @configured = true
8
+ end
9
+ def configured?
10
+ @configured
11
+ end
12
+ end
13
+
14
+ describe 'Rack::Cache::Config' do
15
+ before :each do
16
+ @config = MockConfig.new
17
+ @tempdir = create_temp_directory
18
+ $:.unshift @tempdir
19
+ end
20
+ after :each do
21
+ @config = nil
22
+ $:.shift if $:.first == @tempdir
23
+ remove_entry_secure @tempdir
24
+ end
25
+
26
+ def make_temp_file(filename, data='configured!')
27
+ create_temp_file @tempdir, filename, data
28
+ end
29
+
30
+ it 'loads config files from the load path when file is relative' do
31
+ make_temp_file 'foo/bar.rb'
32
+ @config.import 'foo/bar.rb'
33
+ @config.should.be.configured
34
+ end
35
+ it 'assumes a .rb file extension when no file extension exists' do
36
+ make_temp_file 'foo/bar.rb'
37
+ @config.import 'foo/bar'
38
+ @config.should.be.configured
39
+ end
40
+ it 'does not assume a .rb file extension when other file extension exists' do
41
+ make_temp_file 'foo/bar.conf'
42
+ @config.import 'foo/bar.conf'
43
+ @config.should.be.configured
44
+ end
45
+ it 'should locate files with absolute path names' do
46
+ make_temp_file 'foo/bar.rb'
47
+ @config.import File.join(@tempdir, 'foo/bar.rb')
48
+ @config.should.be.configured
49
+ end
50
+ it 'raises a LoadError when the file cannot be found' do
51
+ assert_raises(LoadError) {
52
+ @config.import('this/file/is/very-likely/not/to/exist.rb')
53
+ }
54
+ end
55
+ it 'executes within the context of the object instance' do
56
+ make_temp_file 'foo/bar.rb',
57
+ 'self.should.be.kind_of Rack::Cache::Config ; configured!'
58
+ @config.import 'foo/bar'
59
+ @config.should.be.configured
60
+ end
61
+ it 'does not import files more than once' do
62
+ make_temp_file 'foo/bar.rb', "import 'foo/bar'"
63
+ @config.import('foo/bar').should.be true
64
+ @config.import('foo/bar').should.be false
65
+ end
66
+ end
@@ -0,0 +1,465 @@
1
+ require "#{File.dirname(__FILE__)}/spec_setup"
2
+ require 'rack/cache/context'
3
+
4
+ describe 'Rack::Cache::Context' do
5
+ before(:each) { setup_cache_context }
6
+ after(:each) { teardown_cache_context }
7
+
8
+ it 'passes on non-GET/HEAD requests' do
9
+ respond_with 200
10
+ post '/'
11
+
12
+ app.should.be.called
13
+ response.should.be.ok
14
+ cache.should.a.performed :pass
15
+ response.headers.should.not.include 'Age'
16
+ end
17
+
18
+ it 'passes on requests with Authorization' do
19
+ respond_with 200
20
+ get '/', 'HTTP_AUTHORIZATION' => 'basic foobarbaz'
21
+
22
+ app.should.be.called
23
+ response.should.be.ok
24
+ cache.should.a.performed :pass
25
+ response.headers.should.not.include 'Age'
26
+ end
27
+
28
+ it 'passes on requests with a Cookie' do
29
+ respond_with 200
30
+ get '/', 'HTTP_COOKIE' => 'foo=bar'
31
+
32
+ response.should.be.ok
33
+ app.should.be.called
34
+ cache.should.a.performed :pass
35
+ response.headers.should.not.include 'Age'
36
+ end
37
+
38
+ it 'caches requests when Cache-Control request header set to no-cache' do
39
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
40
+ get '/', 'HTTP_CACHE_CONTROL' => 'no-cache'
41
+
42
+ response.should.be.ok
43
+ cache.should.a.performed :store
44
+ response.headers.should.not.include 'Age'
45
+ end
46
+
47
+ it 'fetches response from backend when cache misses' do
48
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
49
+ get '/'
50
+
51
+ response.should.be.ok
52
+ cache.should.a.performed :miss
53
+ cache.should.a.performed :fetch
54
+ response.headers.should.not.include 'Age'
55
+ end
56
+
57
+ [(201..202),(204..206),(303..305),(400..403),(405..409),(411..417),(500..505)].each do |range|
58
+ range.each do |response_code|
59
+ it "does not cache #{response_code} responses" do
60
+ respond_with response_code, 'Expires' => (Time.now + 5).httpdate
61
+ get '/'
62
+
63
+ cache.should.a.not.performed :store
64
+ response.status.should.be == response_code
65
+ response.headers.should.not.include 'Age'
66
+ end
67
+ end
68
+ end
69
+
70
+ it "does not cache responses with explicit no-store directive" do
71
+ respond_with 200,
72
+ 'Expires' => (Time.now + 5).httpdate,
73
+ 'Cache-Control' => 'no-store'
74
+ get '/'
75
+
76
+ response.should.be.ok
77
+ cache.should.a.not.performed :store
78
+ response.headers.should.not.include 'Age'
79
+ end
80
+
81
+ it 'does not cache responses without freshness information or a validator' do
82
+ respond_with 200
83
+ get '/'
84
+
85
+ response.should.be.ok
86
+ cache.should.a.not.performed :store
87
+ end
88
+
89
+ it "caches responses with explicit no-cache directive" do
90
+ respond_with 200,
91
+ 'Expires' => (Time.now + 5).httpdate,
92
+ 'Cache-Control' => 'no-cache'
93
+ get '/'
94
+
95
+ response.should.be.ok
96
+ cache.should.a.performed :store
97
+ response.headers.should.not.include 'Age'
98
+ end
99
+
100
+ it 'caches responses with an Expiration header' do
101
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
102
+ get '/'
103
+
104
+ response.should.be.ok
105
+ response.body.should.be == 'Hello World'
106
+ response.headers.should.include 'Date'
107
+ response['Age'].should.be.nil
108
+ response['X-Content-Digest'].should.be.nil
109
+ cache.should.a.performed :miss
110
+ cache.should.a.performed :store
111
+ cache.metastore.to_hash.keys.length.should.be == 1
112
+ end
113
+
114
+ it 'caches responses with a max-age directive' do
115
+ respond_with 200, 'Cache-Control' => 'max-age=5'
116
+ get '/'
117
+
118
+ response.should.be.ok
119
+ response.body.should.be == 'Hello World'
120
+ response.headers.should.include 'Date'
121
+ response['Age'].should.be.nil
122
+ response['X-Content-Digest'].should.be.nil
123
+ cache.should.a.performed :miss
124
+ cache.should.a.performed :store
125
+ cache.metastore.to_hash.keys.length.should.be == 1
126
+ end
127
+
128
+ it 'caches responses with a Last-Modified validator but no freshness information' do
129
+ respond_with 200, 'Last-Modified' => Time.now.httpdate
130
+ get '/'
131
+
132
+ response.should.be.ok
133
+ response.body.should.be == 'Hello World'
134
+ cache.should.a.performed :miss
135
+ cache.should.a.performed :store
136
+ end
137
+
138
+ it 'caches responses with an ETag validator but no freshness information' do
139
+ respond_with 200, 'Etag' => '"123456"'
140
+ get '/'
141
+
142
+ response.should.be.ok
143
+ response.body.should.be == 'Hello World'
144
+ cache.should.a.performed :miss
145
+ cache.should.a.performed :store
146
+ end
147
+
148
+ it 'hits cached response with Expires header' do
149
+ respond_with 200,
150
+ 'Date' => (Time.now - 5).httpdate,
151
+ 'Expires' => (Time.now + 5).httpdate
152
+
153
+ get '/'
154
+ app.should.be.called
155
+ response.should.be.ok
156
+ response.headers.should.include 'Date'
157
+ cache.should.a.performed :miss
158
+ cache.should.a.performed :store
159
+ response.body.should.be == 'Hello World'
160
+
161
+ get '/'
162
+ response.should.be.ok
163
+ app.should.not.be.called
164
+ response['Date'].should.be == responses.first['Date']
165
+ response['Age'].to_i.should.be > 0
166
+ response['X-Content-Digest'].should.not.be.nil
167
+ cache.should.a.performed :hit
168
+ cache.should.a.not.performed :fetch
169
+ response.body.should.be == 'Hello World'
170
+ end
171
+
172
+ it 'hits cached response with max-age directive' do
173
+ respond_with 200,
174
+ 'Date' => (Time.now - 5).httpdate,
175
+ 'Cache-Control' => 'max-age=10'
176
+
177
+ get '/'
178
+ app.should.be.called
179
+ response.should.be.ok
180
+ response.headers.should.include 'Date'
181
+ cache.should.a.performed :miss
182
+ cache.should.a.performed :store
183
+ response.body.should.be == 'Hello World'
184
+
185
+ get '/'
186
+ response.should.be.ok
187
+ app.should.not.be.called
188
+ response['Date'].should.be == responses.first['Date']
189
+ response['Age'].to_i.should.be > 0
190
+ response['X-Content-Digest'].should.not.be.nil
191
+ cache.should.a.performed :hit
192
+ cache.should.a.not.performed :fetch
193
+ response.body.should.be == 'Hello World'
194
+ end
195
+
196
+ it 'fetches full response when cache stale and no validators present' do
197
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
198
+
199
+ # build initial request
200
+ get '/'
201
+ app.should.be.called
202
+ response.should.be.ok
203
+ response.headers.should.include 'Date'
204
+ response.headers.should.not.include 'X-Content-Digest'
205
+ response['Age'].should.be.nil
206
+ cache.should.a.performed :miss
207
+ cache.should.a.performed :store
208
+ response.body.should.be == 'Hello World'
209
+
210
+ # go in and play around with the cached metadata directly ...
211
+ cache.metastore.to_hash.values.length.should.be == 1
212
+ cache.metastore.to_hash.values.first.first[1]['Expires'] = Time.now.httpdate
213
+
214
+ # build subsequent request; should be found but miss due to freshness
215
+ get '/'
216
+ app.should.be.called
217
+ response.should.be.ok
218
+ response['Age'].to_i.should.be == 0
219
+ response['X-Content-Digest'].should.be.nil
220
+ cache.should.a.not.performed :hit
221
+ cache.should.a.not.performed :miss
222
+ cache.should.a.performed :fetch
223
+ cache.should.a.performed :store
224
+ response.body.should.be == 'Hello World'
225
+ end
226
+
227
+ it 'validates cached responses with Last-Modified and no freshness information' do
228
+ timestamp = Time.now.httpdate
229
+ respond_with do |req,res|
230
+ res['Last-Modified'] = timestamp
231
+ if req.env['HTTP_IF_MODIFIED_SINCE'] == timestamp
232
+ res.status = 304
233
+ res.body = []
234
+ end
235
+ end
236
+
237
+ # build initial request
238
+ get '/'
239
+ app.should.be.called
240
+ response.should.be.ok
241
+ response.headers.should.include 'Last-Modified'
242
+ response.headers.should.not.include 'X-Content-Digest'
243
+ response.body.should.be == 'Hello World'
244
+ cache.should.a.performed :miss
245
+ cache.should.a.performed :store
246
+
247
+ # build subsequent request; should be found but miss due to freshness
248
+ get '/'
249
+ app.should.be.called
250
+ response.should.be.ok
251
+ response.headers.should.include 'Last-Modified'
252
+ response.headers.should.include 'X-Content-Digest'
253
+ response['Age'].to_i.should.be == 0
254
+ response['X-Origin-Status'].should.be == '304'
255
+ response.body.should.be == 'Hello World'
256
+ cache.should.a.not.performed :miss
257
+ cache.should.a.performed :fetch
258
+ cache.should.a.performed :store
259
+ end
260
+
261
+ it 'validates cached responses with ETag and no freshness information' do
262
+ timestamp = Time.now.httpdate
263
+ respond_with do |req,res|
264
+ res['ETAG'] = '"12345"'
265
+ if req.env['HTTP_IF_NONE_MATCH'] == res['Etag']
266
+ res.status = 304
267
+ res.body = []
268
+ end
269
+ end
270
+
271
+ # build initial request
272
+ get '/'
273
+ app.should.be.called
274
+ response.should.be.ok
275
+ response.headers.should.include 'Etag'
276
+ response.headers.should.not.include 'X-Content-Digest'
277
+ response.body.should.be == 'Hello World'
278
+ cache.should.a.performed :miss
279
+ cache.should.a.performed :store
280
+
281
+ # build subsequent request; should be found but miss due to freshness
282
+ get '/'
283
+ app.should.be.called
284
+ response.should.be.ok
285
+ response.headers.should.include 'Etag'
286
+ response.headers.should.include 'X-Content-Digest'
287
+ response['Age'].to_i.should.be == 0
288
+ response['X-Origin-Status'].should.be == '304'
289
+ response.body.should.be == 'Hello World'
290
+ cache.should.a.not.performed :miss
291
+ cache.should.a.performed :fetch
292
+ cache.should.a.performed :store
293
+ end
294
+
295
+ it 'replaces cached responses when validation results in non-304 response' do
296
+ timestamp = Time.now.httpdate
297
+ count = 0
298
+ respond_with do |req,res|
299
+ res['Last-Modified'] = timestamp
300
+ case (count+=1)
301
+ when 1 ; res.body = 'first response'
302
+ when 2 ; res.body = 'second response'
303
+ when 3
304
+ res.body = []
305
+ res.status = 304
306
+ end
307
+ end
308
+
309
+ # first request should fetch from backend and store in cache
310
+ get '/'
311
+ response.status.should.be == 200
312
+ response.body.should.be == 'first response'
313
+
314
+ # second request is validated, is invalid, and replaces cached entry
315
+ get '/'
316
+ response.status.should.be == 200
317
+ response.body.should.be == 'second response'
318
+
319
+ # third respone is validated, valid, and returns cached entry
320
+ get '/'
321
+ response.status.should.be == 200
322
+ response.body.should.be == 'second response'
323
+
324
+ count.should.be == 3
325
+ end
326
+
327
+ describe 'with responses that include a Vary header' do
328
+ before(:each) do
329
+ count = 0
330
+ respond_with 200 do |req,res|
331
+ res['Vary'] = 'Accept User-Agent Foo'
332
+ res['Cache-Control'] = 'max-age=10'
333
+ res['X-Response-Count'] = (count+=1).to_s
334
+ res.body = req.env['HTTP_USER_AGENT']
335
+ end
336
+ end
337
+
338
+ it 'serves from cache when headers match' do
339
+ get '/',
340
+ 'HTTP_ACCEPT' => 'text/html',
341
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
342
+ response.should.be.ok
343
+ response.body.should.be == 'Bob/1.0'
344
+ cache.should.a.performed :miss
345
+ cache.should.a.performed :store
346
+
347
+ get '/',
348
+ 'HTTP_ACCEPT' => 'text/html',
349
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
350
+ response.should.be.ok
351
+ response.body.should.be == 'Bob/1.0'
352
+ cache.should.a.performed :hit
353
+ cache.should.a.not.performed :fetch
354
+ response.headers.should.include 'X-Content-Digest'
355
+ end
356
+
357
+ it 'stores multiple responses when headers differ' do
358
+ get '/',
359
+ 'HTTP_ACCEPT' => 'text/html',
360
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
361
+ response.should.be.ok
362
+ response.body.should.be == 'Bob/1.0'
363
+ response['X-Response-Count'].should.be == '1'
364
+
365
+ get '/',
366
+ 'HTTP_ACCEPT' => 'text/html',
367
+ 'HTTP_USER_AGENT' => 'Bob/2.0'
368
+ cache.should.a.performed :miss
369
+ cache.should.a.performed :store
370
+ response.body.should.be == 'Bob/2.0'
371
+ response['X-Response-Count'].should.be == '2'
372
+
373
+ get '/',
374
+ 'HTTP_ACCEPT' => 'text/html',
375
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
376
+ cache.should.a.performed :hit
377
+ response.body.should.be == 'Bob/1.0'
378
+ response['X-Response-Count'].should.be == '1'
379
+
380
+ get '/',
381
+ 'HTTP_ACCEPT' => 'text/html',
382
+ 'HTTP_USER_AGENT' => 'Bob/2.0'
383
+ cache.should.a.performed :hit
384
+ response.body.should.be == 'Bob/2.0'
385
+ response['X-Response-Count'].should.be == '2'
386
+
387
+ get '/',
388
+ 'HTTP_USER_AGENT' => 'Bob/2.0'
389
+ cache.should.a.performed :miss
390
+ response.body.should.be == 'Bob/2.0'
391
+ response['X-Response-Count'].should.be == '3'
392
+ end
393
+ end
394
+
395
+ describe 'when transitioning to the error state' do
396
+
397
+ setup { respond_with(200) }
398
+
399
+ it 'creates a blank slate response object with 500 status with no args' do
400
+ cache_config do
401
+ on(:receive) { error! }
402
+ end
403
+ get '/'
404
+ response.status.should.be == 500
405
+ response.body.should.be.empty
406
+ cache.should.a.performed :error
407
+ end
408
+
409
+ it 'sets the status code with one arg' do
410
+ cache_config do
411
+ on(:receive) { error! 505 }
412
+ end
413
+ get '/'
414
+ response.status.should.be == 505
415
+ end
416
+
417
+ it 'sets the status and headers with args: status, Hash' do
418
+ cache_config do
419
+ on(:receive) { error! 504, 'Content-Type' => 'application/x-foo' }
420
+ end
421
+ get '/'
422
+ response.status.should.be == 504
423
+ response['Content-Type'].should.be == 'application/x-foo'
424
+ response.body.should.be.empty
425
+ end
426
+
427
+ it 'sets the status and body with args: status, String' do
428
+ cache_config do
429
+ on(:receive) { error! 503, 'foo bar baz' }
430
+ end
431
+ get '/'
432
+ response.status.should.be == 503
433
+ response.body.should.be == 'foo bar baz'
434
+ end
435
+
436
+ it 'sets the status and body with args: status, Array' do
437
+ cache_config do
438
+ on(:receive) { error! 503, ['foo bar baz'] }
439
+ end
440
+ get '/'
441
+ response.status.should.be == 503
442
+ response.body.should.be == 'foo bar baz'
443
+ end
444
+
445
+ it 'fires the error event before finishing' do
446
+ fired = false
447
+ cache_config do
448
+ on(:receive) { error! }
449
+ on(:error) {
450
+ fired = true
451
+ response.status.should.be == 500
452
+ response['Content-Type'] = 'application/x-foo'
453
+ response.body = ['overridden response body']
454
+ }
455
+ end
456
+ get '/'
457
+ fired.should.be true
458
+ response.status.should.be == 500
459
+ response.body.should.be == 'overridden response body'
460
+ response['Content-Type'].should.be == 'application/x-foo'
461
+ end
462
+
463
+ end
464
+
465
+ end