rtomayko-rack-cache 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. data/CHANGES +50 -0
  2. data/COPYING +18 -0
  3. data/README +96 -0
  4. data/Rakefile +144 -0
  5. data/TODO +42 -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/config/busters.rb +16 -0
  15. data/lib/rack/cache/config/default.rb +134 -0
  16. data/lib/rack/cache/config/no-cache.rb +13 -0
  17. data/lib/rack/cache/config.rb +65 -0
  18. data/lib/rack/cache/context.rb +95 -0
  19. data/lib/rack/cache/core.rb +271 -0
  20. data/lib/rack/cache/entitystore.rb +224 -0
  21. data/lib/rack/cache/headers.rb +277 -0
  22. data/lib/rack/cache/metastore.rb +292 -0
  23. data/lib/rack/cache/options.rb +119 -0
  24. data/lib/rack/cache/request.rb +37 -0
  25. data/lib/rack/cache/response.rb +76 -0
  26. data/lib/rack/cache/storage.rb +50 -0
  27. data/lib/rack/cache.rb +51 -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 +505 -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 +222 -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 +122 -0
@@ -0,0 +1,505 @@
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 'responds with 304 when If-Modified-Since matches Last-Modified' do
39
+ timestamp = Time.now.httpdate
40
+ respond_with do |req,res|
41
+ res.status = 200
42
+ res['Last-Modified'] = timestamp
43
+ res['Content-Type'] = 'text/plain'
44
+ res.body = ['Hello World']
45
+ end
46
+
47
+ get '/',
48
+ 'HTTP_IF_MODIFIED_SINCE' => timestamp
49
+ app.should.be.called
50
+ response.status.should.be == 304
51
+ response.headers.should.not.include 'Content-Length'
52
+ response.headers.should.not.include 'Content-Type'
53
+ response.body.should.empty
54
+ cache.should.a.performed :miss
55
+ cache.should.a.performed :store
56
+ end
57
+
58
+ it 'responds with 304 when If-None-Match matches ETag' do
59
+ respond_with do |req,res|
60
+ res.status = 200
61
+ res['Etag'] = '12345'
62
+ res['Content-Type'] = 'text/plain'
63
+ res.body = ['Hello World']
64
+ end
65
+
66
+ get '/',
67
+ 'HTTP_IF_NONE_MATCH' => '12345'
68
+ app.should.be.called
69
+ response.status.should.be == 304
70
+ response.headers.should.not.include 'Content-Length'
71
+ response.headers.should.not.include 'Content-Type'
72
+ response.headers.should.include 'Etag'
73
+ response.body.should.empty
74
+ cache.should.a.performed :miss
75
+ cache.should.a.performed :store
76
+ end
77
+
78
+ it 'caches requests when Cache-Control request header set to no-cache' do
79
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
80
+ get '/', 'HTTP_CACHE_CONTROL' => 'no-cache'
81
+
82
+ response.should.be.ok
83
+ cache.should.a.performed :store
84
+ response.headers.should.include 'Age'
85
+ end
86
+
87
+ it 'fetches response from backend when cache misses' do
88
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
89
+ get '/'
90
+
91
+ response.should.be.ok
92
+ cache.should.a.performed :miss
93
+ cache.should.a.performed :fetch
94
+ response.headers.should.include 'Age'
95
+ end
96
+
97
+ [(201..202),(204..206),(303..305),(400..403),(405..409),(411..417),(500..505)].each do |range|
98
+ range.each do |response_code|
99
+ it "does not cache #{response_code} responses" do
100
+ respond_with response_code, 'Expires' => (Time.now + 5).httpdate
101
+ get '/'
102
+
103
+ cache.should.a.not.performed :store
104
+ response.status.should.be == response_code
105
+ response.headers.should.not.include 'Age'
106
+ end
107
+ end
108
+ end
109
+
110
+ it "does not cache responses with explicit no-store directive" do
111
+ respond_with 200,
112
+ 'Expires' => (Time.now + 5).httpdate,
113
+ 'Cache-Control' => 'no-store'
114
+ get '/'
115
+
116
+ response.should.be.ok
117
+ cache.should.a.not.performed :store
118
+ response.headers.should.not.include 'Age'
119
+ end
120
+
121
+ it 'does not cache responses without freshness information or a validator' do
122
+ respond_with 200
123
+ get '/'
124
+
125
+ response.should.be.ok
126
+ cache.should.a.not.performed :store
127
+ end
128
+
129
+ it "caches responses with explicit no-cache directive" do
130
+ respond_with 200,
131
+ 'Expires' => (Time.now + 5).httpdate,
132
+ 'Cache-Control' => 'no-cache'
133
+ get '/'
134
+
135
+ response.should.be.ok
136
+ cache.should.a.performed :store
137
+ response.headers.should.include 'Age'
138
+ end
139
+
140
+ it 'caches responses with an Expiration header' do
141
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
142
+ get '/'
143
+
144
+ response.should.be.ok
145
+ response.body.should.be == 'Hello World'
146
+ response.headers.should.include 'Date'
147
+ response['Age'].should.not.be.nil
148
+ response['X-Content-Digest'].should.not.be.nil
149
+ cache.should.a.performed :miss
150
+ cache.should.a.performed :store
151
+ cache.metastore.to_hash.keys.length.should.be == 1
152
+ end
153
+
154
+ it 'caches responses with a max-age directive' do
155
+ respond_with 200, 'Cache-Control' => 'max-age=5'
156
+ get '/'
157
+
158
+ response.should.be.ok
159
+ response.body.should.be == 'Hello World'
160
+ response.headers.should.include 'Date'
161
+ response['Age'].should.not.be.nil
162
+ response['X-Content-Digest'].should.not.be.nil
163
+ cache.should.a.performed :miss
164
+ cache.should.a.performed :store
165
+ cache.metastore.to_hash.keys.length.should.be == 1
166
+ end
167
+
168
+ it 'caches responses with a Last-Modified validator but no freshness information' do
169
+ respond_with 200, 'Last-Modified' => Time.now.httpdate
170
+ get '/'
171
+
172
+ response.should.be.ok
173
+ response.body.should.be == 'Hello World'
174
+ cache.should.a.performed :miss
175
+ cache.should.a.performed :store
176
+ end
177
+
178
+ it 'caches responses with an ETag validator but no freshness information' do
179
+ respond_with 200, 'Etag' => '"123456"'
180
+ get '/'
181
+
182
+ response.should.be.ok
183
+ response.body.should.be == 'Hello World'
184
+ cache.should.a.performed :miss
185
+ cache.should.a.performed :store
186
+ end
187
+
188
+ it 'hits cached response with Expires header' do
189
+ respond_with 200,
190
+ 'Date' => (Time.now - 5).httpdate,
191
+ 'Expires' => (Time.now + 5).httpdate
192
+
193
+ get '/'
194
+ app.should.be.called
195
+ response.should.be.ok
196
+ response.headers.should.include 'Date'
197
+ cache.should.a.performed :miss
198
+ cache.should.a.performed :store
199
+ response.body.should.be == 'Hello World'
200
+
201
+ get '/'
202
+ response.should.be.ok
203
+ app.should.not.be.called
204
+ response['Date'].should.be == responses.first['Date']
205
+ response['Age'].to_i.should.be > 0
206
+ response['X-Content-Digest'].should.not.be.nil
207
+ cache.should.a.performed :hit
208
+ cache.should.a.not.performed :fetch
209
+ response.body.should.be == 'Hello World'
210
+ end
211
+
212
+ it 'hits cached response with max-age directive' do
213
+ respond_with 200,
214
+ 'Date' => (Time.now - 5).httpdate,
215
+ 'Cache-Control' => 'max-age=10'
216
+
217
+ get '/'
218
+ app.should.be.called
219
+ response.should.be.ok
220
+ response.headers.should.include 'Date'
221
+ cache.should.a.performed :miss
222
+ cache.should.a.performed :store
223
+ response.body.should.be == 'Hello World'
224
+
225
+ get '/'
226
+ response.should.be.ok
227
+ app.should.not.be.called
228
+ response['Date'].should.be == responses.first['Date']
229
+ response['Age'].to_i.should.be > 0
230
+ response['X-Content-Digest'].should.not.be.nil
231
+ cache.should.a.performed :hit
232
+ cache.should.a.not.performed :fetch
233
+ response.body.should.be == 'Hello World'
234
+ end
235
+
236
+ it 'fetches full response when cache stale and no validators present' do
237
+ respond_with 200, 'Expires' => (Time.now + 5).httpdate
238
+
239
+ # build initial request
240
+ get '/'
241
+ app.should.be.called
242
+ response.should.be.ok
243
+ response.headers.should.include 'Date'
244
+ response.headers.should.include 'X-Content-Digest'
245
+ response.headers.should.include 'Age'
246
+ cache.should.a.performed :miss
247
+ cache.should.a.performed :store
248
+ response.body.should.be == 'Hello World'
249
+
250
+ # go in and play around with the cached metadata directly ...
251
+ cache.metastore.to_hash.values.length.should.be == 1
252
+ cache.metastore.to_hash.values.first.first[1]['Expires'] = Time.now.httpdate
253
+
254
+ # build subsequent request; should be found but miss due to freshness
255
+ get '/'
256
+ app.should.be.called
257
+ response.should.be.ok
258
+ response['Age'].to_i.should.be == 0
259
+ response.headers.should.include 'X-Content-Digest'
260
+ cache.should.a.not.performed :hit
261
+ cache.should.a.not.performed :miss
262
+ cache.should.a.performed :fetch
263
+ cache.should.a.performed :store
264
+ response.body.should.be == 'Hello World'
265
+ end
266
+
267
+ it 'validates cached responses with Last-Modified and no freshness information' do
268
+ timestamp = Time.now.httpdate
269
+ respond_with do |req,res|
270
+ res['Last-Modified'] = timestamp
271
+ if req.env['HTTP_IF_MODIFIED_SINCE'] == timestamp
272
+ res.status = 304
273
+ res.body = []
274
+ end
275
+ end
276
+
277
+ # build initial request
278
+ get '/'
279
+ app.should.be.called
280
+ response.should.be.ok
281
+ response.headers.should.include 'Last-Modified'
282
+ response.headers.should.include 'X-Content-Digest'
283
+ response.body.should.be == 'Hello World'
284
+ cache.should.a.performed :miss
285
+ cache.should.a.performed :store
286
+
287
+ # build subsequent request; should be found but miss due to freshness
288
+ get '/'
289
+ app.should.be.called
290
+ response.should.be.ok
291
+ response.headers.should.include 'Last-Modified'
292
+ response.headers.should.include 'X-Content-Digest'
293
+ response['Age'].to_i.should.be == 0
294
+ response['X-Origin-Status'].should.be == '304'
295
+ response.body.should.be == 'Hello World'
296
+ cache.should.a.not.performed :miss
297
+ cache.should.a.performed :fetch
298
+ cache.should.a.performed :store
299
+ end
300
+
301
+ it 'validates cached responses with ETag and no freshness information' do
302
+ timestamp = Time.now.httpdate
303
+ respond_with do |req,res|
304
+ res['ETAG'] = '"12345"'
305
+ if req.env['HTTP_IF_NONE_MATCH'] == res['Etag']
306
+ res.status = 304
307
+ res.body = []
308
+ end
309
+ end
310
+
311
+ # build initial request
312
+ get '/'
313
+ app.should.be.called
314
+ response.should.be.ok
315
+ response.headers.should.include 'Etag'
316
+ response.headers.should.include 'X-Content-Digest'
317
+ response.body.should.be == 'Hello World'
318
+ cache.should.a.performed :miss
319
+ cache.should.a.performed :store
320
+
321
+ # build subsequent request; should be found but miss due to freshness
322
+ get '/'
323
+ app.should.be.called
324
+ response.should.be.ok
325
+ response.headers.should.include 'Etag'
326
+ response.headers.should.include 'X-Content-Digest'
327
+ response['Age'].to_i.should.be == 0
328
+ response['X-Origin-Status'].should.be == '304'
329
+ response.body.should.be == 'Hello World'
330
+ cache.should.a.not.performed :miss
331
+ cache.should.a.performed :fetch
332
+ cache.should.a.performed :store
333
+ end
334
+
335
+ it 'replaces cached responses when validation results in non-304 response' do
336
+ timestamp = Time.now.httpdate
337
+ count = 0
338
+ respond_with do |req,res|
339
+ res['Last-Modified'] = timestamp
340
+ case (count+=1)
341
+ when 1 ; res.body = 'first response'
342
+ when 2 ; res.body = 'second response'
343
+ when 3
344
+ res.body = []
345
+ res.status = 304
346
+ end
347
+ end
348
+
349
+ # first request should fetch from backend and store in cache
350
+ get '/'
351
+ response.status.should.be == 200
352
+ response.body.should.be == 'first response'
353
+
354
+ # second request is validated, is invalid, and replaces cached entry
355
+ get '/'
356
+ response.status.should.be == 200
357
+ response.body.should.be == 'second response'
358
+
359
+ # third respone is validated, valid, and returns cached entry
360
+ get '/'
361
+ response.status.should.be == 200
362
+ response.body.should.be == 'second response'
363
+
364
+ count.should.be == 3
365
+ end
366
+
367
+ describe 'with responses that include a Vary header' do
368
+ before(:each) do
369
+ count = 0
370
+ respond_with 200 do |req,res|
371
+ res['Vary'] = 'Accept User-Agent Foo'
372
+ res['Cache-Control'] = 'max-age=10'
373
+ res['X-Response-Count'] = (count+=1).to_s
374
+ res.body = req.env['HTTP_USER_AGENT']
375
+ end
376
+ end
377
+
378
+ it 'serves from cache when headers match' do
379
+ get '/',
380
+ 'HTTP_ACCEPT' => 'text/html',
381
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
382
+ response.should.be.ok
383
+ response.body.should.be == 'Bob/1.0'
384
+ cache.should.a.performed :miss
385
+ cache.should.a.performed :store
386
+
387
+ get '/',
388
+ 'HTTP_ACCEPT' => 'text/html',
389
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
390
+ response.should.be.ok
391
+ response.body.should.be == 'Bob/1.0'
392
+ cache.should.a.performed :hit
393
+ cache.should.a.not.performed :fetch
394
+ response.headers.should.include 'X-Content-Digest'
395
+ end
396
+
397
+ it 'stores multiple responses when headers differ' do
398
+ get '/',
399
+ 'HTTP_ACCEPT' => 'text/html',
400
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
401
+ response.should.be.ok
402
+ response.body.should.be == 'Bob/1.0'
403
+ response['X-Response-Count'].should.be == '1'
404
+
405
+ get '/',
406
+ 'HTTP_ACCEPT' => 'text/html',
407
+ 'HTTP_USER_AGENT' => 'Bob/2.0'
408
+ cache.should.a.performed :miss
409
+ cache.should.a.performed :store
410
+ response.body.should.be == 'Bob/2.0'
411
+ response['X-Response-Count'].should.be == '2'
412
+
413
+ get '/',
414
+ 'HTTP_ACCEPT' => 'text/html',
415
+ 'HTTP_USER_AGENT' => 'Bob/1.0'
416
+ cache.should.a.performed :hit
417
+ response.body.should.be == 'Bob/1.0'
418
+ response['X-Response-Count'].should.be == '1'
419
+
420
+ get '/',
421
+ 'HTTP_ACCEPT' => 'text/html',
422
+ 'HTTP_USER_AGENT' => 'Bob/2.0'
423
+ cache.should.a.performed :hit
424
+ response.body.should.be == 'Bob/2.0'
425
+ response['X-Response-Count'].should.be == '2'
426
+
427
+ get '/',
428
+ 'HTTP_USER_AGENT' => 'Bob/2.0'
429
+ cache.should.a.performed :miss
430
+ response.body.should.be == 'Bob/2.0'
431
+ response['X-Response-Count'].should.be == '3'
432
+ end
433
+ end
434
+
435
+ describe 'when transitioning to the error state' do
436
+
437
+ setup { respond_with(200) }
438
+
439
+ it 'creates a blank slate response object with 500 status with no args' do
440
+ cache_config do
441
+ on(:receive) { error! }
442
+ end
443
+ get '/'
444
+ response.status.should.be == 500
445
+ response.body.should.be.empty
446
+ cache.should.a.performed :error
447
+ end
448
+
449
+ it 'sets the status code with one arg' do
450
+ cache_config do
451
+ on(:receive) { error! 505 }
452
+ end
453
+ get '/'
454
+ response.status.should.be == 505
455
+ end
456
+
457
+ it 'sets the status and headers with args: status, Hash' do
458
+ cache_config do
459
+ on(:receive) { error! 504, 'Content-Type' => 'application/x-foo' }
460
+ end
461
+ get '/'
462
+ response.status.should.be == 504
463
+ response['Content-Type'].should.be == 'application/x-foo'
464
+ response.body.should.be.empty
465
+ end
466
+
467
+ it 'sets the status and body with args: status, String' do
468
+ cache_config do
469
+ on(:receive) { error! 503, 'foo bar baz' }
470
+ end
471
+ get '/'
472
+ response.status.should.be == 503
473
+ response.body.should.be == 'foo bar baz'
474
+ end
475
+
476
+ it 'sets the status and body with args: status, Array' do
477
+ cache_config do
478
+ on(:receive) { error! 503, ['foo bar baz'] }
479
+ end
480
+ get '/'
481
+ response.status.should.be == 503
482
+ response.body.should.be == 'foo bar baz'
483
+ end
484
+
485
+ it 'fires the error event before finishing' do
486
+ fired = false
487
+ cache_config do
488
+ on(:receive) { error! }
489
+ on(:error) {
490
+ fired = true
491
+ response.status.should.be == 500
492
+ response['Content-Type'] = 'application/x-foo'
493
+ response.body = ['overridden response body']
494
+ }
495
+ end
496
+ get '/'
497
+ fired.should.be true
498
+ response.status.should.be == 500
499
+ response.body.should.be == 'overridden response body'
500
+ response['Content-Type'].should.be == 'application/x-foo'
501
+ end
502
+
503
+ end
504
+
505
+ end
data/test/core_test.rb ADDED
@@ -0,0 +1,84 @@
1
+ require "#{File.dirname(__FILE__)}/spec_setup"
2
+ require 'rack/cache/core'
3
+
4
+ class MockCore
5
+ include Rack::Cache::Core
6
+ alias_method :initialize, :initialize_core
7
+ public :transition, :trigger, :events
8
+ end
9
+
10
+ describe 'Rack::Cache::Core' do
11
+ before :each do
12
+ @core = MockCore.new
13
+ end
14
+
15
+ it 'has events after instantiation' do
16
+ @core.events.should.respond_to :[]
17
+ end
18
+ it 'defines and triggers event handlers' do
19
+ executed = false
20
+ @core.on(:foo) { executed = true }
21
+ @core.trigger :foo
22
+ executed.should.be true
23
+ end
24
+ it 'executes multiple handlers in LIFO order' do
25
+ x = 'nothing executed'
26
+ @core.on :foo do
27
+ x.should.be == 'bottom executed'
28
+ x = 'top executed'
29
+ end
30
+ @core.on :foo do
31
+ x.should.be == 'nothing executed'
32
+ x = 'bottom executed'
33
+ end
34
+ @core.trigger :foo
35
+ x.should.be == 'top executed'
36
+ end
37
+ it 'records event execution history' do
38
+ @core.on(:foo) {}
39
+ @core.trigger :foo
40
+ @core.should.a.performed :foo
41
+ end
42
+ it 'raises an exception when asked to perform an unknown event' do
43
+ assert_raises NameError do
44
+ @core.trigger :foo
45
+ end
46
+ end
47
+ it 'raises an exception when asked to transition to an unknown event' do
48
+ @core.on(:bling) {}
49
+ @core.on(:foo) { throw(:transition, [:bling]) }
50
+ lambda { @core.transition(from=:foo, to=[:bar, :baz]) }.
51
+ should.raise Rack::Cache::IllegalTransition
52
+ end
53
+ it 'passes transition arguments to handlers' do
54
+ passed = nil
55
+ @core.meta_def(:perform_bar) do |*args|
56
+ passed = args
57
+ 'hi'
58
+ end
59
+ @core.on(:bar) {}
60
+ @core.on(:foo) { throw(:transition, [:bar, 1, 2, 3]) }
61
+ result = @core.transition(from=:foo, to=[:bar])
62
+ passed.should.be == [1,2,3]
63
+ result.should.be == 'hi'
64
+ end
65
+ it 'fully transitions out of handlers when the next event is invoked' do
66
+ x = []
67
+ @core.on(:foo) {
68
+ x << 'in foo, before transitioning to bar'
69
+ throw(:transition, [:bar])
70
+ x << 'in foo, after transitioning to bar'
71
+ }
72
+ @core.on(:bar) { x << 'in bar' }
73
+ @core.trigger(:foo).should.be == [:bar]
74
+ @core.trigger(:bar).should.be.nil
75
+ x.should.be == [
76
+ 'in foo, before transitioning to bar',
77
+ 'in bar'
78
+ ]
79
+ end
80
+ it 'returns the transition event name' do
81
+ @core.on(:foo) { throw(:transition, [:bar]) }
82
+ @core.trigger(:foo).should.be == [:bar]
83
+ end
84
+ end