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.
- checksums.yaml +7 -0
- data/Gemfile +2 -0
- data/LICENSE +7 -0
- data/Milestones.md +65 -0
- data/README.md +78 -0
- data/docs/be_creative.md +32 -0
- data/docs/filters.md +8 -0
- data/docs/routing.md +29 -0
- data/docs/sharing_request_state.md +5 -0
- data/examples/media_types.rb +18 -0
- data/examples/media_types.ru +2 -0
- data/lib/scorched.rb +19 -0
- data/lib/scorched/collection.rb +60 -0
- data/lib/scorched/controller.rb +343 -0
- data/lib/scorched/dynamic_delegate.rb +22 -0
- data/lib/scorched/error.rb +5 -0
- data/lib/scorched/options.rb +54 -0
- data/lib/scorched/request.rb +34 -0
- data/lib/scorched/response.rb +18 -0
- data/lib/scorched/static.rb +16 -0
- data/lib/scorched/version.rb +3 -0
- data/lib/scorched/view_helpers.rb +39 -0
- data/scorched.gemspec +19 -0
- data/spec/collection_spec.rb +46 -0
- data/spec/controller_spec.rb +565 -0
- data/spec/helper.rb +44 -0
- data/spec/options_spec.rb +42 -0
- data/spec/public/static.txt +1 -0
- data/spec/request_spec.rb +2 -0
- data/spec/view_helpers_spec.rb +84 -0
- data/spec/views/composer.erb +1 -0
- data/spec/views/layout.erb +1 -0
- data/spec/views/main.erb +1 -0
- data/spec/views/other.str +1 -0
- data/spec/views/partial.erb +1 -0
- metadata +157 -0
@@ -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,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,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
|
data/scorched.gemspec
ADDED
@@ -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
|