http_router 0.0.1

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,250 @@
1
+ $LOAD_PATH << File.dirname(__FILE__)
2
+ require 'rack'
3
+ require 'rack/uri_escape'
4
+
5
+ class HttpRouter
6
+ autoload :Node, 'http_router/node'
7
+ autoload :Root, 'http_router/root'
8
+ autoload :Variable, 'http_router/variable'
9
+ autoload :Glob, 'http_router/glob'
10
+ autoload :Route, 'http_router/route'
11
+ autoload :Response, 'http_router/response'
12
+ autoload :Path, 'http_router/path'
13
+
14
+ UngeneratableRouteException = Class.new(RuntimeError)
15
+ MissingParameterException = Class.new(RuntimeError)
16
+ TooManyParametersException = Class.new(RuntimeError)
17
+ RoutingError = Struct.new(:status, :headers)
18
+
19
+ attr_reader :routes
20
+
21
+ def initialize(options = nil)
22
+ reset!
23
+ @default_app = options && options[:default_app] || proc{|env| ::Rack::Response.new("Not Found", 404).finish }
24
+ @ignore_trailing_slash = options && options.key?(:ignore_trailing_slash) ? options[:ignore_trailing_slash] : true
25
+ @redirect_trailing_slash = options && options.key?(:redirect_trailing_slash) ? options[:redirect_trailing_slash] : false
26
+ end
27
+
28
+ def ignore_trailing_slash?
29
+ @ignore_trailing_slash
30
+ end
31
+
32
+ def redirect_trailing_slash?
33
+ @redirect_trailing_slash
34
+ end
35
+
36
+ def reset!
37
+ @root = Root.new(self)
38
+ @routes = {}
39
+ end
40
+
41
+ def default(app)
42
+ @default_app = app
43
+ end
44
+
45
+ def split(path, with_delimiter = false)
46
+ path.slice!(0) if path[0] == ?/
47
+ with_delimiter ? path.split('(/)') : path.split('/')
48
+ end
49
+
50
+ def add(path, options = nil)
51
+ path = path.dup
52
+ partially_match = extract_partial_match(path)
53
+ trailing_slash_ignore = extract_trailing_slash(path)
54
+ paths = compile(path, options)
55
+
56
+ route = Route.new(self, options && options[:default_values])
57
+ route.trailing_slash_ignore = trailing_slash_ignore
58
+ route.partially_match = partially_match
59
+ paths.each_with_index do |path, i|
60
+ current_node = @root
61
+ path.parts.each { |part| current_node = current_node.add(part) }
62
+ working_set = current_node.add_request_methods(options)
63
+ working_set.each do |current_node|
64
+ current_node.value = path
65
+ path.route = route
66
+ route.paths << current_node.value
67
+ end
68
+ end
69
+ route
70
+ end
71
+
72
+ def get(path, options = {})
73
+ options[:conditions] ||= {}
74
+ options[:conditions][:request_method] = ['HEAD', 'GET'] #TODO, this should be able to take an array
75
+ add(path, options)
76
+ end
77
+
78
+ def post(path, options = {})
79
+ options[:conditions] ||= {}
80
+ options[:conditions][:request_method] = 'POST'
81
+ add(path, options)
82
+ end
83
+
84
+ def put(path, options = {})
85
+ options[:conditions] ||= {}
86
+ options[:conditions][:request_method] = 'PUT'
87
+ add(path, options)
88
+ end
89
+
90
+ def delete(path, options = {})
91
+ options[:conditions] ||= {}
92
+ options[:conditions][:request_method] = 'DELETE'
93
+ add(path, options)
94
+ end
95
+
96
+ def only_get(path, options = {})
97
+ options[:conditions] ||= {}
98
+ options[:conditions][:request_method] = "GET"
99
+ add(path, options)
100
+ end
101
+
102
+ def extract_partial_match(path)
103
+ if path[-1] == ?*
104
+ path.slice!(-1)
105
+ true
106
+ else
107
+ false
108
+ end
109
+ end
110
+
111
+ def extract_trailing_slash(path)
112
+ if path[-2, 2] == '/?'
113
+ path.slice!(-2, 2)
114
+ true
115
+ else
116
+ false
117
+ end
118
+ end
119
+
120
+ def extract_extension(path)
121
+ if match = path.match(/^(.*)(\.:([a-zA-Z_]+))$/)
122
+ path.replace(match[1])
123
+ Variable.new(self, match[3].to_sym)
124
+ elsif match = path.match(/^(.*)(\.([a-zA-Z_]+))$/)
125
+ path.replace(match[1])
126
+ match[3]
127
+ end
128
+ end
129
+
130
+ def compile(path, options)
131
+ start_index = 0
132
+ end_index = 1
133
+
134
+ paths = [""]
135
+ chars = path.split('')
136
+
137
+ chars.each do |c|
138
+ case c
139
+ when '('
140
+ # over current working set, double paths
141
+ (start_index...end_index).each do |path_index|
142
+ paths << paths[path_index].dup
143
+ end
144
+ start_index = end_index
145
+ end_index = paths.size
146
+ when ')'
147
+ start_index -= end_index - start_index
148
+ else
149
+ (start_index...end_index).each do |path_index|
150
+ paths[path_index] << c
151
+ end
152
+ end
153
+ end
154
+
155
+ variables = {}
156
+ paths.map do |path|
157
+ original_path = path.dup
158
+ extension = extract_extension(path)
159
+ new_path = split(path).map do |part|
160
+ case part[0]
161
+ when ?:
162
+ v_name = part[1, part.size].to_sym
163
+ variables[v_name] ||= Variable.new(self, v_name, options && options[:matches_with] && options && options[:matches_with][v_name])
164
+ when ?*
165
+ v_name = part[1, part.size].to_sym
166
+ variables[v_name] ||= Glob.new(self, v_name, options && options[:matches_with] && options && options[:matches_with][v_name])
167
+ else
168
+ part_segments = part.split(/(:[a-zA-Z_]+)/)
169
+ if part_segments.size > 1
170
+ index = 0
171
+ part_segments.map do |seg|
172
+ new_seg = if seg[0] == ?:
173
+ next_index = index + 1
174
+ scan_regex = if next_index == part_segments.size
175
+ /^[^\/]+/
176
+ else
177
+ /^.*?(?=#{Regexp.quote(part_segments[next_index])})/
178
+ end
179
+ v_name = seg[1, seg.size].to_sym
180
+ variables[v_name] ||= Variable.new(self, v_name, scan_regex)
181
+ else
182
+ /^#{Regexp.quote(seg)}/
183
+ end
184
+ index += 1
185
+ new_seg
186
+ end
187
+ else
188
+ part
189
+ end
190
+ end
191
+ end
192
+ new_path.flatten!
193
+ Path.new(original_path, new_path, extension)
194
+ end
195
+ end
196
+
197
+ def call(env)
198
+ request = Rack::Request.new(env)
199
+ if redirect_trailing_slash? && (request.head? || request.get?) && request.path_info[-1] == ?/
200
+ response = Rack::Response.new
201
+ response.redirect(request.path_info[0, request.path_info.size - 1], 302)
202
+ response.finish
203
+ else
204
+ response = recognize(request)
205
+ env['router'] = self
206
+ if response.is_a?(RoutingError)
207
+ [response.status, response.headers, []]
208
+ elsif response && response.route.dest && response.route.dest.respond_to?(:call)
209
+ process_params(env, response)
210
+ consume_path!(request, response) if response.partial_match?
211
+ #if response.rest
212
+ # request.env["SCRIPT_NAME"] += request.env["PATH_INFO"][0, -response.rest.size]
213
+ # request.env["PATH_INFO"] = response.rest || ''
214
+ #end
215
+ response.route.dest.call(env)
216
+ else
217
+ @default_app.call(env)
218
+ end
219
+ end
220
+ end
221
+
222
+ def consume_path!(request, response)
223
+ request.env["SCRIPT_NAME"] = (request.env["SCRIPT_NAME"] + response.matched_path)
224
+ request.env["PATH_INFO"] = response.remaining_path || ""
225
+ end
226
+
227
+ def process_params(env, response)
228
+ if env.key?('router.params')
229
+ env['router.params'].merge!(response.route.default_values) if response.route.default_values
230
+ env['router.params'].merge!(response.params_as_hash)
231
+ else
232
+ env['router.params'] = response.route.default_values ? response.route.default_values.merge(response.params_as_hash) : response.params_as_hash
233
+ end
234
+ end
235
+
236
+ def recognize(env)
237
+ response = @root.find(env.is_a?(Hash) ? Rack::Request.new(env) : env)
238
+ end
239
+
240
+ def url(route, *args)
241
+ case route
242
+ when Symbol
243
+ url(@routes[route], *args)
244
+ when nil
245
+ raise UngeneratableRouteException.new
246
+ else
247
+ route.url(*args)
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,38 @@
1
+ unless Rack::Utils.respond_to?(:uri_escape)
2
+ module Rack
3
+ module Utils
4
+ def uri_escape(s)
5
+ s.to_s.gsub(/([^:\/?\[\]\-_~\.!\$&'\(\)\*\+,;=@a-zA-Z0-9]+)/n) {
6
+ '%'<<$1.unpack('H2'*$1.size).join('%').upcase
7
+ }
8
+ end
9
+ module_function :uri_escape
10
+ end
11
+ end
12
+ end
13
+
14
+ unless Rack::Utils.respond_to?(:uri_escape!)
15
+ module Rack
16
+ module Utils
17
+ def uri_escape!(s)
18
+ s.to_s.gsub!(/([^:\/?\[\]\-_~\.!\$&'\(\)\*\+,;=@a-zA-Z0-9]+)/n) {
19
+ '%'<<$1.unpack('H2'*$1.size).join('%').upcase
20
+ }
21
+ end
22
+ module_function :uri_escape!
23
+ end
24
+ end
25
+ end
26
+
27
+ unless Rack::Utils.respond_to?(:uri_unescape)
28
+ module Rack
29
+ module Utils
30
+ def uri_unescape(s)
31
+ gsub(/((?:%[0-9a-fA-F]{2})+)/n){
32
+ [$1.delete('%')].pack('H*')
33
+ }
34
+ end
35
+ module_function :uri_unescape
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ describe "HttpRouter#generate" do
2
+ before(:each) do
3
+ @router = HttpRouter.new
4
+ end
5
+
6
+ context("static paths") do
7
+ ['/', '/test', '/test/time', '/one/more/what', '/test.html'].each do |path|
8
+ it "should generate #{path.inspect}" do
9
+ route = @router.add(path)
10
+ @router.url(route).should == path
11
+ end
12
+ end
13
+ end
14
+
15
+ context("dynamic paths") do
16
+ it "should generate from a hash" do
17
+ @router.add("/:var").name(:test)
18
+ @router.url(:test, :var => 'test').should == '/test'
19
+ end
20
+
21
+ it "should generate from an array" do
22
+ @router.add("/:var").name(:test)
23
+ @router.url(:test, 'test').should == '/test'
24
+ end
25
+
26
+ it "should generate with a format" do
27
+ @router.add("/test.:format").name(:test)
28
+ @router.url(:test, 'html').should == '/test.html'
29
+ end
30
+
31
+ it "should generate with a format as a hash" do
32
+ @router.add("/test.:format").name(:test)
33
+ @router.url(:test, :format => 'html').should == '/test.html'
34
+ end
35
+
36
+ it "should generate with an optional format" do
37
+ @router.add("/test(.:format)").name(:test)
38
+ @router.url(:test, 'html').should == '/test.html'
39
+ @router.url(:test).should == '/test'
40
+ end
41
+
42
+ context "with optional parts" do
43
+ it "should generate both" do
44
+ @router.add("/:var1(/:var2)").name(:test)
45
+ @router.url(:test, 'var').should == '/var'
46
+ @router.url(:test, 'var', 'fooz').should == '/var/fooz'
47
+ @router.url(:test, :var1 => 'var').should == '/var'
48
+ @router.url(:test, :var1 => 'var', :var2 => 'fooz').should == '/var/fooz'
49
+ proc{@router.url(:test, :var2 => 'fooz').should == '/var/fooz'}.should raise_error(HttpRouter::UngeneratableRouteException)
50
+ end
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,113 @@
1
+ route_set = HttpRouter.new
2
+ route_set.extend(CallWithMockRequestMixin)
3
+
4
+ describe "Usher (for rack) route dispatching with redirect_on_trailing_delimiters" do
5
+ before(:each) do
6
+ @route_set = HttpRouter.new(:redirect_trailing_slash => true)
7
+ @route_set.extend(CallWithMockRequestMixin)
8
+ @app = MockApp.new("Hello World!")
9
+ @route_set.add('/sample').to(@app)
10
+ end
11
+
12
+ it "should dispatch a request" do
13
+ response = @route_set.call_with_mock_request('/sample/')
14
+ response.headers["Location"].should == "/sample"
15
+ end
16
+
17
+ end
18
+
19
+ describe "Usher (for rack) route dispatching" do
20
+ before(:each) do
21
+ route_set.reset!
22
+ @app = MockApp.new("Hello World!")
23
+ end
24
+
25
+ describe "HTTP GET" do
26
+ before(:each) do
27
+ route_set.reset!
28
+ route_set.add('/sample', :conditions => {:request_method => 'GET'}).to(@app)
29
+ end
30
+
31
+ it "should dispatch a request" do
32
+ response = route_set.call_with_mock_request
33
+ response.body.should eql("Hello World!")
34
+ end
35
+
36
+ it "should write router.params" do
37
+ response = route_set.call_with_mock_request
38
+ @app.env["router.params"].should == {}
39
+ end
40
+ end
41
+
42
+ describe "HTTP POST" do
43
+ before(:each) do
44
+ route_set.reset!
45
+ route_set.add('/sample', :conditions => {:request_method => 'POST'}).to(@app)
46
+ route_set.add('/sample').to(MockApp.new("You shouldn't get here if you are using POST"))
47
+ end
48
+
49
+ it "should dispatch a POST request" do
50
+ response = route_set.call_with_mock_request('/sample', 'POST')
51
+ response.body.should eql("Hello World!")
52
+ end
53
+
54
+ it "shouldn't dispatch a GET request" do
55
+ response = route_set.call_with_mock_request('/sample', 'GET')
56
+ response.body.should eql("You shouldn't get here if you are using POST")
57
+ end
58
+
59
+ it "should write router.params" do
60
+ response = route_set.call_with_mock_request("/sample", 'POST')
61
+ @app.env["router.params"].should == {}
62
+ end
63
+ end
64
+
65
+ it "should returns HTTP 405 if the method mis-matches" do
66
+ route_set.reset!
67
+ route_set.add('/sample', :conditions => {:request_method => 'POST'}).to(@app)
68
+ route_set.add('/sample', :conditions => {:request_method => 'PUT'}).to(@app)
69
+ response = route_set.call_with_mock_request('/sample', 'GET')
70
+ response.status.should eql(405)
71
+ response['Allow'].should == 'POST, PUT'
72
+ end
73
+
74
+ it "should returns HTTP 404 if route doesn't exist" do
75
+ response = route_set.call_with_mock_request("/not-existing-url")
76
+ response.status.should eql(404)
77
+ end
78
+
79
+ describe "shortcuts" do
80
+ describe "get" do
81
+ before(:each) do
82
+ route_set.reset!
83
+ route_set.get('/sample').to(@app)
84
+ end
85
+
86
+ it "should dispatch a GET request" do
87
+ response = route_set.call_with_mock_request("/sample", "GET")
88
+ response.body.should eql("Hello World!")
89
+ end
90
+
91
+ it "should dispatch a HEAD request" do
92
+ response = route_set.call_with_mock_request("/sample", "HEAD")
93
+ response.body.should eql("Hello World!")
94
+ end
95
+ end
96
+ end
97
+
98
+ describe "non rack app destinations" do
99
+ it "should route to a default application when using a hash" do
100
+ $captures = []
101
+ @default_app = lambda do |e|
102
+ $captures << :default
103
+ Rack::Response.new("Default").finish
104
+ end
105
+ @router = HttpRouter.new
106
+ @router.default(@default_app)
107
+ @router.add("/default").to(:action => "default")
108
+ response = @router.call(Rack::MockRequest.env_for("/default"))
109
+ $captures.should == [:default]
110
+ end
111
+ end
112
+
113
+ end
@@ -0,0 +1,27 @@
1
+ route_set = HttpRouter.new
2
+ route_set.extend(CallWithMockRequestMixin)
3
+
4
+ describe "Usher (for rack) route generation" do
5
+ before(:each) do
6
+ route_set.reset!
7
+ @app = MockApp.new("Hello World!")
8
+ route_set.add("/fixed").name(:fixed)
9
+ route_set.add("/named/simple/:named_simple_var").name(:simple)
10
+ route_set.add("/named/optional(/:named_optional_var)").name(:optional)
11
+ end
12
+
13
+ describe "named routes" do
14
+ it "should generate a fixed path" do
15
+ route_set.url(:fixed).should == "/fixed"
16
+ end
17
+
18
+ it "should generate a named path route" do
19
+ route_set.url(:simple, :named_simple_var => "the_var").should == "/named/simple/the_var"
20
+ end
21
+
22
+ it "should generate a named route with options" do
23
+ route_set.url(:optional).should == "/named/optional"
24
+ route_set.url(:optional, :named_optional_var => "the_var").should == "/named/optional/the_var"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,70 @@
1
+ describe "Rack interface extensions for Usher::Route" do
2
+ before(:each) do
3
+ @route_set = HttpRouter.new
4
+ @app = MockApp.new("Hello World!")
5
+ @env = Rack::MockRequest.env_for("/index.html")
6
+ end
7
+
8
+ describe "basic functinality" do
9
+ it "should set redirect headers" do
10
+ @route_set.get("/index.html").redirect("/")
11
+ raw_response = @route_set.call(@env)
12
+ response = Rack::MockResponse.new(*raw_response)
13
+ response.should be_redirect
14
+ end
15
+
16
+ it "should redirect '/index.html' to '/'" do
17
+ @route_set.get("/index.html").redirect("/")
18
+ status, headers, body = @route_set.call(@env)
19
+ headers["Location"].should eql("/")
20
+ end
21
+
22
+ it "should redirect '/:id.html' to '/:id'" do
23
+ @route_set.get("/:id.html").redirect('/#{params[:id]}')
24
+ @env = Rack::MockRequest.env_for("/123.html")
25
+ status, headers, body = @route_set.call(@env)
26
+ headers["Location"].should eql("/123")
27
+ end
28
+ end
29
+
30
+ describe "static file serving" do
31
+ it "should serve from a static directory" do
32
+ @route_set.get("/static").serves_static_from(File.dirname(__FILE__))
33
+ @env = Rack::MockRequest.env_for("/static/#{File.basename(__FILE__)}")
34
+ status, headers, body = @route_set.call(@env)
35
+ body.path.should == File.join(File.dirname(__FILE__), File.basename(__FILE__))
36
+ end
37
+
38
+ it "should serve a specific file" do
39
+ @route_set.get("/static-file").serves_static_from(__FILE__)
40
+ @env = Rack::MockRequest.env_for("/static-file")
41
+ status, headers, body = @route_set.call(@env)
42
+ body.path.should == __FILE__
43
+ end
44
+ end
45
+
46
+ describe "chaining" do
47
+ it "should be chainable" do
48
+ @route_set.get("/index.html").redirect("/").name(:root)
49
+ url = @route_set.url(:root)
50
+ url.should eql("/index.html")
51
+ end
52
+
53
+ it "should not influence actual invoking" do
54
+ @route_set.get("/index.html").redirect("/").name(:root)
55
+ @route_set.call(@env)
56
+ end
57
+ end
58
+
59
+ describe "custom status" do
60
+ it "should enable to set custom HTTP status" do
61
+ @route_set.get("/index.html").redirect("/", 303)
62
+ status, headers, body = @route_set.call(@env)
63
+ status.should eql(303)
64
+ end
65
+
66
+ it "should raise an exception if given HTTP code isn't a redirection" do
67
+ lambda { @route_set.get("/index.html").redirect("/", 200) }.should raise_error(ArgumentError)
68
+ end
69
+ end
70
+ end