scorched 0.5

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