rtomayko-rack-cache 0.2.0

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.
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