scorched 0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,22 @@
1
+ module Scorched
2
+ # Unlike most delegator's that delegate to an object, this delegator delegates to a runtime expression, and so the
3
+ # target object can be dynamic.
4
+ module DynamicDelegate
5
+ def delegate(target_literal, *methods)
6
+ methods.each do |method|
7
+ method = method.to_sym
8
+ class_eval <<-CODE
9
+ def #{method}(*args, &block)
10
+ #{target_literal}.__send__(#{method.inspect}, *args, &block)
11
+ end
12
+ CODE
13
+ end
14
+ end
15
+
16
+ def alias_each(methods)
17
+ methods.each do |m|
18
+ alias_method yield(m), m
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ module Scorched
2
+ class Error < StandardError
3
+
4
+ end
5
+ end
@@ -0,0 +1,54 @@
1
+ module Scorched
2
+ class Options < Hash
3
+ # Redefine all methods as delegates of the underlying local hash.
4
+ extend DynamicDelegate
5
+ alias_each(Hash.instance_methods(false)) { |m| "_#{m}" }
6
+ delegate 'to_hash', *Hash.instance_methods(false).reject { |m|
7
+ [:[]=, :clear, :delete, :delete_if, :merge!, :replace, :shift, :store].include? m
8
+ }
9
+
10
+ alias_method :<<, :replace
11
+
12
+ # sets parent Options object and returns self
13
+ def parent!(parent)
14
+ @parent = parent
15
+ self
16
+ end
17
+
18
+ def to_hash(inherit = true)
19
+ (inherit && Hash === @parent) ? @parent.to_hash.merge(self) : {}.merge(self)
20
+ end
21
+
22
+ def inspect
23
+ "#<#{self.class}: local#{_inspect}, merged#{to_hash.inspect}>"
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def Options(accessor_name)
29
+ m = Module.new
30
+ m.class_eval <<-MOD
31
+ class << self
32
+ def included(klass)
33
+ klass.extend(ClassMethods)
34
+ end
35
+ end
36
+
37
+ module ClassMethods
38
+ def #{accessor_name}
39
+ @#{accessor_name} || begin
40
+ parent = superclass.#{accessor_name} if superclass.respond_to?(:#{accessor_name}) && Scorched::Options === superclass.#{accessor_name}
41
+ @#{accessor_name} = Options.new.parent!(parent)
42
+ end
43
+ end
44
+ end
45
+
46
+ def #{accessor_name}(*args)
47
+ self.class.#{accessor_name}(*args)
48
+ end
49
+ MOD
50
+ m
51
+ end
52
+ end
53
+ end
54
+
@@ -0,0 +1,34 @@
1
+ module Scorched
2
+ class Request < Rack::Request
3
+ # Keeps track of the matched URL portions and what object handled them.
4
+ def breadcrumb
5
+ env['breadcrumb'] ||= []
6
+ end
7
+
8
+ # Returns a hash of captured strings from the last matched URL in the breadcrumb.
9
+ def captures
10
+ breadcrumb.last ? breadcrumb.last[:captures] : []
11
+ end
12
+
13
+ def all_captures
14
+ breadcrumb.map { |v| v[:captures] }
15
+ end
16
+
17
+ def matched_path
18
+ join_paths(breadcrumb.map{|v| v[:url]})
19
+ end
20
+
21
+ def unmatched_path
22
+ path = path_info.partition(matched_path).last
23
+ path[0,0] = '/' unless path[0] == '/'
24
+ path
25
+ end
26
+
27
+ private
28
+
29
+ # Joins an array of path segments ensuring a single forward slash seperates them.
30
+ def join_paths(paths)
31
+ paths.join('/').gsub(%r{/+}, '/')
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ module Scorched
2
+ class Response < Rack::Response
3
+ # Merges another response object (or response array) into self in order to preserve references to this response
4
+ # object.
5
+ def merge!(response)
6
+ return self if response == self
7
+ if Rack::Response === response
8
+ response.finish
9
+ self.status = response.status
10
+ self.header.merge!(response.header)
11
+ self.body = []
12
+ response.each { |v| self.body << v }
13
+ else
14
+ self.status, @header, self.body = response
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ module Scorched
2
+ class Static
3
+ def initialize(app, options={})
4
+ @app = app
5
+ @options = options
6
+ dir = options.delete(:dir) || 'public'
7
+ options[:cache_control] ||= 'no-cache'
8
+ @file_server = Rack::File.new(dir, options)
9
+ end
10
+
11
+ def call(env)
12
+ response = @file_server.call(env)
13
+ response[0] >= 400 ? @app.call(env) : response
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module Scorched
2
+ VERSION = '0.5'
3
+ end
@@ -0,0 +1,39 @@
1
+ # In its own file for no other reason than to keep all the extra non-essential bells and whistles in their own file,
2
+ # which can be easily excluded if needed.
3
+
4
+ module Scorched
5
+ module ViewHelpers
6
+
7
+ # Renders the given string or file path using the Tilt templating library.
8
+ # Options hash is merged with the controllers _view_config_. Tilt template options are passed through.
9
+ # The template engine is derived from file name, or otherwise as specified by the _:engine_ option. If String is
10
+ # given, _:engine_ option must be set.
11
+ #
12
+ # Refer to Tilt documentation for a list of valid template engines.
13
+ def render(string_or_file, options = {}, &block)
14
+ options = view_config.merge(explicit_options = options)
15
+ engine = (derived_engine = Tilt[string_or_file.to_s]) || Tilt[options[:engine]]
16
+ raise Error, "Invalid or undefined template engine: #{options[:engine].inspect}" unless engine
17
+ if Symbol === string_or_file
18
+ file = string_or_file.to_s
19
+ file = file << ".#{options[:engine]}" unless derived_engine
20
+ file = File.join(options[:dir], file) if options[:dir]
21
+ template = engine.new(file, nil, options)
22
+ else
23
+ template = engine.new(nil, nil, options) { string_or_file }
24
+ end
25
+
26
+ # The following chunk of code is responsible for preventing the rendering of layouts within views.
27
+ options[:layout] = false if @_no_default_layout && !explicit_options[:layout]
28
+ begin
29
+ @_no_default_layout = true
30
+ output = template.render(self, options[:locals], &block)
31
+ ensure
32
+ @_no_default_layout = false
33
+ end
34
+ output = render(options[:layout], options.merge(layout: false)) { output } if options[:layout]
35
+ output
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,19 @@
1
+ $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
2
+ require 'scorched/version' # Load scorched to inspect it for information, such as version.
3
+
4
+ Gem::Specification.new 'scorched', Scorched::VERSION do |s|
5
+ s.summary = "Light-weight, DRY as a desert, web framework for Ruby"
6
+ s.description = "A lightweight Sinatra-inspired web framework for web sites and applications of any size."
7
+ s.authors = ["Tom Wardrop"]
8
+ s.email = "tom@tomwardrop.com"
9
+ s.homepage = "http://scorchedrb.com"
10
+ s.files = Dir.glob(`git ls-files`.split("\n") - %w[.gitignore])
11
+ s.test_files = Dir.glob('spec/**/*_spec.rb')
12
+ s.rdoc_options = %w[--line-numbers --inline-source --title Scorched --encoding=UTF-8]
13
+
14
+ s.add_dependency 'rack', '~> 1.4'
15
+ s.add_dependency 'rack-accept', '~> 0.4.5'
16
+ s.add_dependency 'tilt', '~> 1.3'
17
+ s.add_development_dependency 'rack-test', '~> 0.6'
18
+ s.add_development_dependency 'rspec', '~> 2.9'
19
+ end
@@ -0,0 +1,46 @@
1
+ require_relative './helper.rb'
2
+
3
+ class CollectionA
4
+ include Scorched::Collection('middleware')
5
+ end
6
+
7
+ class CollectionB < CollectionA
8
+ end
9
+
10
+ class CollectionC < CollectionB
11
+ end
12
+
13
+ module Scorched
14
+ describe Collection do
15
+ it "defaults to an empty set" do
16
+ CollectionA.middleware.should == Set.new
17
+ end
18
+
19
+ it "can be set to a given set" do
20
+ my_set = Set.new(['horse', 'cat', 'dog'])
21
+ CollectionA.middleware.replace my_set
22
+ CollectionA.middleware.should == my_set
23
+ end
24
+
25
+ it "automatically converts arrays to sets" do
26
+ array = ['horse', 'cat', 'dog']
27
+ CollectionA.middleware.replace array
28
+ CollectionA.middleware.should == array.to_set
29
+ end
30
+
31
+ it "recursively inherits from parents by default" do
32
+ CollectionB.middleware.should == CollectionA.middleware
33
+ CollectionC.middleware.should == CollectionA.middleware
34
+ end
35
+
36
+ it "allows values to be overridden without modifying the parent" do
37
+ CollectionB.middleware << 'rabbit'
38
+ CollectionB.middleware.should include('rabbit')
39
+ CollectionA.middleware.should_not include('rabbit')
40
+ end
41
+
42
+ it "provides access to a copy of internal set" do
43
+ CollectionB.middleware.to_set(false).should == Set.new(['rabbit'])
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,565 @@
1
+ require_relative './helper.rb'
2
+
3
+ module Scorched
4
+ describe Controller do
5
+ let(:generic_handler) do
6
+ proc { |env| [200, {}, ['ok']] }
7
+ end
8
+
9
+ it "contains a default set of configuration options" do
10
+ app.config.should be_a(Options)
11
+ app.config.length.should > 0
12
+ end
13
+
14
+ it "contains a set of default conditions" do
15
+ app.conditions.should be_a(Options)
16
+ app.conditions.length.should > 0
17
+ app.conditions[:methods].should be_a(Proc)
18
+ end
19
+
20
+ describe "basic route handling" do
21
+ it "gracefully handles 404 errors" do
22
+ response = rt.get '/'
23
+ response.status.should == 404
24
+ end
25
+
26
+ it "handles a root rack call correctly" do
27
+ app << {url: '/$', target: generic_handler}
28
+ response = rt.get '/'
29
+ response.status.should == 200
30
+ end
31
+
32
+ it "does not maintain state between requests" do
33
+ app << {url: '/state', target: proc { |env| [200, {}, [@state = 1 + @state.to_i]] }}
34
+ response = rt.get '/state'
35
+ response.body.should == '1'
36
+ response = rt.get '/state'
37
+ response.body.should == '1'
38
+ end
39
+
40
+ it "raises exception when invalid mapping hash given" do
41
+ expect {
42
+ app << {url: '/'}
43
+ }.to raise_error(ArgumentError)
44
+ expect {
45
+ app << {target: generic_handler}
46
+ }.to raise_error(ArgumentError)
47
+ end
48
+ end
49
+
50
+ describe "URL matching" do
51
+ it 'always matches from the beginning of the URL' do
52
+ app << {url: 'about', target: generic_handler}
53
+ response = rt.get '/about'
54
+ response.status.should == 404
55
+ end
56
+
57
+ it "matches eagerly by default" do
58
+ request = nil
59
+ app << {url: '/*', target: proc do |env|
60
+ request = env['rack.request']; [200, {}, ['ok']]
61
+ end}
62
+ response = rt.get '/about'
63
+ request.captures.should == ['about']
64
+ end
65
+
66
+ it "can be forced to match end of URL" do
67
+ app << {url: '/about$', target: generic_handler}
68
+ response = rt.get '/about/us'
69
+ response.status.should == 404
70
+ app << {url: '/about', target: generic_handler}
71
+ response = rt.get '/about/us'
72
+ response.status.should == 200
73
+ end
74
+
75
+ it "can match anonymous wildcards" do
76
+ request = nil
77
+ app << {url: '/anon/*/**', target: proc do |env|
78
+ request = env['rack.request']; [200, {}, ['ok']]
79
+ end}
80
+ response = rt.get '/anon/jeff/has/crabs'
81
+ request.captures.should == ['jeff', 'has/crabs']
82
+ end
83
+
84
+ it "can match named wildcards (ignoring anonymous captures)" do
85
+ request = nil
86
+ app << {url: '/anon/:name/*/::infliction', target: proc do |env|
87
+ request = env['rack.request']; [200, {}, ['ok']]
88
+ end}
89
+ response = rt.get '/anon/jeff/smith/has/crabs'
90
+ request.captures.should == {name: 'jeff', infliction: 'has/crabs'}
91
+ end
92
+
93
+ it "can match regex and preserve anonymous captures" do
94
+ request = nil
95
+ app << {url: %r{/anon/([^/]+)/(.+)}, target: proc do |env|
96
+ request = env['rack.request']; [200, {}, ['ok']]
97
+ end}
98
+ response = rt.get '/anon/jeff/has/crabs'
99
+ request.captures.should == ['jeff', 'has/crabs']
100
+ end
101
+
102
+ it "can match regex and preserve named captures (ignoring anonymous captures)" do
103
+ request = nil
104
+ app << {url: %r{/anon/(?<name>[^/]+)/([^/]+)/(?<infliction>.+)}, target: proc do |env|
105
+ request = env['rack.request']; [200, {}, ['ok']]
106
+ end}
107
+ response = rt.get '/anon/jeff/smith/has/crabs'
108
+ request.captures.should == {name: 'jeff', infliction: 'has/crabs'}
109
+ end
110
+
111
+ it "matches routes based on priority, otherwise giving precedence to those defined first" do
112
+ app << {url: '/', priority: -1, target: proc { |env| self.class.mappings.shift; [200, {}, ['four']] }}
113
+ app << {url: '/', target: proc { |env| self.class.mappings.shift; [200, {}, ['two']] }}
114
+ app << {url: '/', target: proc { |env| self.class.mappings.shift; [200, {}, ['three']] }}
115
+ app << {url: '/', priority: 2, target: proc { |env| self.class.mappings.shift; [200, {}, ['one']] }}
116
+ rt.get('/').body.should == 'one'
117
+ rt.get('/').body.should == 'two'
118
+ rt.get('/').body.should == 'three'
119
+ rt.get('/').body.should == 'four'
120
+ end
121
+ end
122
+
123
+ describe "conditions" do
124
+ it "contains a default set of conditions" do
125
+ app.conditions.should be_a(Options)
126
+ app.conditions.should include(:methods, :media_type)
127
+ app.conditions.each { |k,v| v.should be_a(Proc) }
128
+ end
129
+
130
+ it "executes route only if all conditions return true" do
131
+ app << {url: '/', conditions: {methods: 'POST'}, target: generic_handler}
132
+ response = rt.get "/"
133
+ response.status.should == 404
134
+ response = rt.post "/"
135
+ response.status.should == 200
136
+
137
+ app.conditions[:has_name] = proc { |name| request.GET['name'] }
138
+ app << {url: '/about', conditions: {methods: ['GET', 'POST'], has_name: 'Ronald'}, target: generic_handler}
139
+ response = rt.get "/about"
140
+ response.status.should == 404
141
+ response = rt.get "/about", name: 'Ronald'
142
+ response.status.should == 200
143
+ end
144
+
145
+ it "raises exception when condition doesn't exist or is invalid" do
146
+ app << {url: '/', conditions: {surprise_christmas_turkey: true}, target: generic_handler}
147
+ expect {
148
+ rt.get "/"
149
+ }.to raise_error(Scorched::Error)
150
+ end
151
+
152
+ it "falls through to next route when conditions are not met" do
153
+ app << {url: '/', conditions: {methods: 'POST'}, target: proc { |env| [200, {}, ['post']] }}
154
+ app << {url: '/', conditions: {methods: 'GET'}, target: proc { |env| [200, {}, ['get']] }}
155
+ rt.get("/").body.should == 'get'
156
+ rt.post("/").body.should == 'post'
157
+ end
158
+ end
159
+
160
+ describe "route helpers" do
161
+ it "allows end points to be defined more succinctly" do
162
+ route_proc = app.route('/*', 2, methods: 'GET') { |capture| capture }
163
+ mapping = app.mappings.first
164
+ mapping.should == {url: mapping[:url], priority: 2, conditions: {methods: 'GET'}, target: route_proc}
165
+ rt.get('/about').body.should == 'about'
166
+ end
167
+
168
+ it "can provide a mapping proc without mapping it" do
169
+ block = proc { |capture| capture }
170
+ wrapped_block = app.route(&block)
171
+ app.mappings.length.should == 0
172
+ block.should_not == wrapped_block
173
+ app << {url: '/*', target: wrapped_block}
174
+ rt.get('/turkey').body.should == 'turkey'
175
+ end
176
+
177
+ it "provides a method for every HTTP method" do
178
+ [:get, :post, :put, :delete, :options, :head, :patch].each do |m|
179
+ app.send(m, '/say_cool') { 'cool' }
180
+ rt.send(m, '/say_cool').body.should == (m == :head ? '' : 'cool')
181
+ end
182
+ end
183
+
184
+ it "always matches to the end of the URL (implied $)" do
185
+ app.get('/') { 'awesome '}
186
+ rt.get('/dog').status.should == 404
187
+ rt.get('/').status.should == 200
188
+ end
189
+ end
190
+
191
+ describe "sub-controllers" do
192
+ it "can be given no arguments" do
193
+ app.controller do
194
+ get('/') { 'hello' }
195
+ end
196
+ response = rt.get('/')
197
+ response.status.should == 200
198
+ response.body.should == 'hello'
199
+ end
200
+
201
+ it "can take mapping options" do
202
+ app.controller priority: -1, conditions: {methods: 'POST'} do
203
+ route('/') { 'ok' }
204
+ end
205
+ app.mappings.first[:priority].should == -1
206
+ rt.get('/').status.should == 404
207
+ rt.post('/').body.should == 'ok'
208
+ end
209
+
210
+ it "should ignore the already matched portions of the path" do
211
+ app.controller url: '/article' do
212
+ get('/*') { |title| title }
213
+ end
214
+ rt.get('/article/hello-world').body.should == 'hello-world'
215
+ end
216
+
217
+ it "inherits from parent class, or any other class" do
218
+ app.controller.superclass.should == app
219
+ app.controller(String).superclass.should == String
220
+ end
221
+ end
222
+
223
+ describe "before/after filters" do
224
+ they "run directly before and after the target action" do
225
+ order = []
226
+ app.get('/') { order << :action }
227
+ app.after { order << :after }
228
+ app.before { order << :before }
229
+ rt.get('/')
230
+ order.should == [:before, :action, :after]
231
+ end
232
+
233
+ they "run in the context of the controller (same as the route)" do
234
+ route_instance = nil
235
+ before_instance = nil
236
+ after_instance = nil
237
+ app.get('/') { route_instance = self }
238
+ app.before { before_instance = self }
239
+ app.after { after_instance = self }
240
+ rt.get('/')
241
+ route_instance.should == before_instance
242
+ route_instance.should == after_instance
243
+ end
244
+
245
+ they "should run even if no route matches" do
246
+ counter = 0
247
+ app.before { counter += 1 }
248
+ app.after { counter += 1 }
249
+ rt.delete('/').status.should == 404
250
+ counter.should == 2
251
+ end
252
+
253
+ they "can take an optional set of conditions" do
254
+ counter = 0
255
+ app.before(methods: ['GET', 'PUT']) { counter += 1 }
256
+ app.after(methods: ['GET', 'PUT']) { counter += 1 }
257
+ rt.post('/')
258
+ rt.get('/')
259
+ rt.put('/')
260
+ counter.should == 4
261
+ end
262
+
263
+ describe "nesting" do
264
+ example "filters inherit but only run once" do
265
+ before_counter, after_counter = 0, 0
266
+ app.before { before_counter += 1 }
267
+ app.after { after_counter += 1 }
268
+ subcontroller = app.controller { get('/') { 'wow' } }
269
+ subcontroller.filters[:before].should == app.filters[:before]
270
+ subcontroller.filters[:after].should == app.filters[:after]
271
+
272
+ rt.get('/')
273
+ before_counter.should == 1
274
+ after_counter.should == 1
275
+
276
+ # Hitting the subcontroller directly should yield the same results.
277
+ before_counter, after_counter = 0, 0
278
+ Rack::Test::Session.new(subcontroller).get('/')
279
+ before_counter.should == 1
280
+ after_counter.should == 1
281
+ end
282
+
283
+ example "before filters run from outermost to inner" do
284
+ order = []
285
+ app.before { order << :outer }
286
+ app.controller { before { order << :inner } }
287
+ rt.get('/')
288
+ order.should == [:outer, :inner]
289
+ end
290
+
291
+ example "after filters run from innermost to outermost" do
292
+ order = []
293
+ app.after { order << :outer }
294
+ app.controller { after { order << :inner } }
295
+ rt.get('/')
296
+ order.should == [:inner, :outer]
297
+ end
298
+ end
299
+ end
300
+
301
+ describe "error filters" do
302
+ let(:app) do
303
+ Class.new(Scorched::Controller) do
304
+ route '/' do
305
+ raise StandardError
306
+ end
307
+ end
308
+ end
309
+
310
+ they "catch exceptions" do
311
+ app.error { response.status = 500 }
312
+ rt.get('/').status.should == 500
313
+ end
314
+
315
+ they "receive the exception object as their first argument" do
316
+ error = nil
317
+ app.error { |e| error = e }
318
+ rt.get('/')
319
+ error.should be_a(StandardError)
320
+ end
321
+
322
+ they "try the next handler if the previous handler returns false" do
323
+ handlers_called = 0
324
+ app.error { handlers_called += 1 }
325
+ app.error { handlers_called += 1 }
326
+ rt.get '/'
327
+ handlers_called.should == 1
328
+
329
+ app.error_filters.clear
330
+ handlers_called = 0
331
+ app.error { handlers_called += 1; false }
332
+ app.error { handlers_called += 1 }
333
+ rt.get '/'
334
+ handlers_called.should == 2
335
+ end
336
+
337
+ they "still runs after filters if route error is handled" do
338
+ app.after { response.status = 111 }
339
+ app.error { true }
340
+ rt.get('/').status.should == 111
341
+ end
342
+
343
+ they "can handle exceptions in before/after filters" do
344
+ app.error { |e| response.write e.class.name }
345
+ app.after { raise ArgumentError }
346
+ rt.get('/').body.should == 'StandardErrorArgumentError'
347
+ end
348
+
349
+ they "only get called once per error" do
350
+ times_called = 0
351
+ app.error { times_called += 1 }
352
+ rt.get '/'
353
+ times_called.should == 1
354
+ end
355
+
356
+ they "fall through when unhandled" do
357
+ expect {
358
+ rt.get '/'
359
+ }.to raise_error(StandardError)
360
+ end
361
+
362
+ they "can optionally filter on one or more exception types" do
363
+ app.get('/arg_error') { raise ArgumentError }
364
+
365
+ app.error(StandardError, ArgumentError) { true }
366
+ rt.get '/'
367
+ rt.get '/arg_error'
368
+
369
+ app.error_filters.clear
370
+ app.error(ArgumentError) { true }
371
+ expect {
372
+ rt.get '/'
373
+ }.to raise_error(StandardError)
374
+ rt.get '/arg_error'
375
+ end
376
+
377
+ they "can take an optional set of conditions" do
378
+ app.error(methods: ['GET', 'PUT']) { true }
379
+ expect {
380
+ rt.post('/')
381
+ }.to raise_error(StandardError)
382
+ rt.get('/')
383
+ rt.put('/')
384
+ end
385
+ end
386
+
387
+ describe "middleware" do
388
+ let(:app) do
389
+ Class.new(Scorched::Controller) do
390
+ self.middleware << proc { use Scorched::SimpleCounter }
391
+ get '/'do
392
+ request.env['scorched.simple_counter']
393
+ end
394
+ controller url: '/sub_controller' do
395
+ get '/' do
396
+ request.env['scorched.simple_counter']
397
+ end
398
+ end
399
+ end
400
+ end
401
+
402
+ it "is only included once by default" do
403
+ rt.get('/').body.should == '1'
404
+ rt.get('/sub_controller').body.should == '1'
405
+ end
406
+
407
+ it "can be explicitly included more than once in sub-controllers" do
408
+ app.mappings[-1][:target].middleware << proc { use Scorched::SimpleCounter }
409
+ rt.get('/').body.should == '1'
410
+ rt.get('/sub_controller').body.should == '2'
411
+ end
412
+ end
413
+
414
+ describe "halting" do
415
+ it "short circuits current request" do
416
+ has_run = false
417
+ app.get('/') { halt; has_run = true }
418
+ rt.get '/'
419
+ has_run.should be_false
420
+ end
421
+
422
+ it "takes an optional status" do
423
+ app.get('/') { halt 401 }
424
+ rt.get('/').status.should == 401
425
+ end
426
+
427
+ it "still processes filters" do
428
+ app.after { response.status = 403 }
429
+ app.get('/') { halt }
430
+ rt.get('/').status.should == 403
431
+ end
432
+
433
+ it "short circuits filters if halted within filter" do
434
+ app.before { halt }
435
+ app.after { response.status = 403 }
436
+ rt.get('/').status.should == 200
437
+ end
438
+ end
439
+
440
+ describe "configuration" do
441
+ describe "strip_trailing_slash" do
442
+ it "is set to redirect by default" do
443
+ app.config[:strip_trailing_slash].should == :redirect
444
+ app.get('/test') { }
445
+ response = rt.get('/test/')
446
+ response.status.should == 307
447
+ response['Location'].should == '/test'
448
+ end
449
+
450
+ it "can be set to ignore trailing slash while pattern matching" do
451
+ app.config[:strip_trailing_slash] = :ignore
452
+ hit = false
453
+ app.get('/test') { hit = true }
454
+ rt.get('/test/').status.should == 200
455
+ hit.should == true
456
+ end
457
+
458
+ it "can be set not do nothing with a trailing slash" do
459
+ app.config[:strip_trailing_slash] = false
460
+ app.get('/test') { }
461
+ rt.get('/test/').status.should == 404
462
+
463
+ app.get('/test/') { }
464
+ rt.get('/test/').status.should == 200
465
+ end
466
+ end
467
+
468
+ describe "static_dir" do
469
+ it "is set to serve static files from 'public' directory by default" do
470
+ app.config[:static_dir].should == 'public'
471
+ response = rt.get('/static.txt')
472
+ response.status.should == 200
473
+ response.body.should == 'My static file!'
474
+ end
475
+
476
+ it "can be disabled" do
477
+ app.config[:static_dir] = false
478
+ response = rt.get('/static.txt')
479
+ response.status.should == 404
480
+ end
481
+ end
482
+
483
+ describe "sessions" do
484
+ it "provides convenience method for accessing the Rack session" do
485
+ rack_session = nil
486
+ app.get('/') { rack_session = session }
487
+ rt.get('/')
488
+ rack_session.should be_nil
489
+ app.middleware << proc { use Rack::Session::Cookie, secret: 'test' }
490
+ rt.get('/')
491
+ rack_session.should be_a(Rack::Session::Abstract::SessionHash)
492
+ end
493
+
494
+ describe "flash" do
495
+ before(:each) do
496
+ app.middleware << proc { use Rack::Session::Cookie, secret: 'test' }
497
+ end
498
+
499
+ it "keeps session variables that live for one page load" do
500
+ app.get('/set') { flash[:cat] = 'meow' }
501
+ app.get('/get') { flash[:cat] }
502
+
503
+ rt.get('/set')
504
+ rt.get('/get').body.should == 'meow'
505
+ rt.get('/get').body.should == ''
506
+ end
507
+
508
+ it "always reads from the original request flash" do
509
+ app.get('/') do
510
+ flash[:counter] = flash[:counter] ? flash[:counter] + 1 : 0
511
+ flash[:counter].to_s
512
+ end
513
+
514
+ rt.get('/').body.should == ''
515
+ rt.get('/').body.should == '0'
516
+ rt.get('/').body.should == '1'
517
+ end
518
+
519
+ it "can only remove flash variables if the flash object is accessed" do
520
+ app.get('/set') { flash[:cat] = 'meow' }
521
+ app.get('/get') { flash[:cat] }
522
+ app.get('/null') { }
523
+
524
+ rt.get('/set')
525
+ rt.get('/null')
526
+ rt.get('/get').body.should == 'meow'
527
+ rt.get('/get').body.should == ''
528
+ end
529
+
530
+ it "can keep multiple sets of flash session variables" do
531
+ app.get('/set_animal') { flash(:animals)[:cat] = 'meow' }
532
+ app.get('/get_animal') { flash(:animals)[:cat] }
533
+ app.get('/set_name') { flash(:names)[:jeff] = 'male' }
534
+ app.get('/get_name') { flash(:names)[:jeff] }
535
+
536
+ rt.get('/set_animal')
537
+ rt.get('/set_name')
538
+ rt.get('/get_animal').body.should == 'meow'
539
+ rt.get('/get_name').body.should == 'male'
540
+ rt.get('/get_animal').body.should == ''
541
+ rt.get('/get_name').body.should == ''
542
+ end
543
+ end
544
+ end
545
+
546
+ describe "cookie helper" do
547
+ it "sets, retrieves and deletes cookies" do
548
+ app.get('/') { cookie :test }
549
+ app.post('/') { cookie :test, 'hello' }
550
+ app.post('/goodbye') { cookie :test, {value: 'goodbye', expires: Time.now() + 999999 } }
551
+ app.delete('/') { cookie :test, nil }
552
+
553
+ rt.get('/').body.should == ''
554
+ rt.post('/')
555
+ rt.get('/').body.should == 'hello'
556
+ rt.post('/goodbye')
557
+ rt.get('/').body.should == 'goodbye'
558
+ rt.delete('/')
559
+ rt.get('/').body.should == ''
560
+ end
561
+ end
562
+
563
+ end
564
+ end
565
+ end