http_router 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  == Introduction
4
4
 
5
- When I wrote Usher, I made a few compromised in design that I wasn't totally happy with. More and more features got added to it, and eventually, it became harder to maintain. I took a few moments to work in Node.js, and wrote a router there called Sherpa, which I was happier with. But I felt that by losing more abstraction, and tackling just the problem of HTTP routing, I could come up with something even better.
5
+ When I wrote Usher, I made a few compromises in design that I wasn't totally happy with. More and more features got added to it, and eventually, it became harder to maintain. I took a few moments to work in Node.js, and wrote a router there called Sherpa, which I was happier with. But I felt that by losing more abstraction, and tackling just the problem of HTTP routing, I could come up with something even better.
6
+
7
+ == Warning
8
+
9
+ This is very new code. Lots of stuff probably doesn't work right. I will likely never support all the features I had in Usher. Documentation is super-sparse.
6
10
 
7
11
  == Features
8
12
 
@@ -38,14 +42,16 @@ e.g.
38
42
 
39
43
  r = HttpRouter.new
40
44
  r.add('/test/:variable(.:format)').name(:my_test_path).to {|env| [200, {}, "Hey dude #{env['router.params'][:variable]}"]}
41
- r.add('/test').redirect_to("http://www.google.com")
42
- r.add('/static').serves_static_from('/my_file_system')
45
+ r.add('/test').redirect("http://www.google.com/")
46
+ r.add('/static').static('/my_file_system')
43
47
 
44
- As well, you can support regex matching and request conditions. To add a regex match, use <tt>:matches_with => { :id => /\d+/ }</tt>.
45
- To match on a request condition you can use <tt>:conditions => {:request_method => %w(POST HEAD)}</tt>.
48
+ As well, you can support regex matching and request conditions. To add a regex match, use <tt>matching(:id => /\d+/)</tt>.
49
+ To match on a request condition you can use <tt>condition(:request_method => %w(POST HEAD))</tt> or more succinctly <tt>request_method('POST', 'HEAD')</tt>.
46
50
 
47
51
  There are convenience methods HttpRouter#get, HttpRouter#post, etc for each request method.
48
52
 
53
+ Routes will not be recognized unless <tt>#to</tt> has been called on it.
54
+
49
55
  === <tt>#url(name or route, *args)</tt>
50
56
 
51
57
  Generates a route. The args can either be a hash, a list, or a mix of both.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.1
1
+ 0.1.0
@@ -1,7 +1,7 @@
1
1
  class HttpRouter
2
2
  class Node
3
- attr_accessor :value, :variable, :catchall, :request_node
4
- attr_reader :linear, :lookup
3
+ attr_accessor :value, :variable, :catchall
4
+ attr_reader :linear, :lookup, :request_node, :extension_node
5
5
 
6
6
  def initialize
7
7
  reset!
@@ -32,71 +32,117 @@ class HttpRouter
32
32
  end
33
33
  end
34
34
 
35
+ def add_extension(ext)
36
+ @extension_node ||= Node.new
37
+ @extension_node.add(ext)
38
+ end
39
+
35
40
  def add_request_methods(options)
36
- if options && options[:conditions]
37
- current_nodes = [@request_node ||= RequestNode.new]
38
- request_options = options[:conditions]
39
- RequestNode::RequestMethods.each do |method|
40
- current_nodes.each_with_index do |current_node, current_node_index|
41
- if request_options[method] #we care about the method
42
- if current_node # and we have to pay attention to what currently is there.
43
- unless current_node.request_method
44
- current_node.request_method = method
45
- end
46
-
47
- case RequestNode::RequestMethods.index(method) <=> RequestNode::RequestMethods.index(current_node.request_method)
48
- when 0 #use this node
49
- if request_options[method].is_a?(Regexp)
50
- current_node = RequestNode.new
51
- current_node.linear << [request_options[method], current_node]
52
- elsif request_options[method].is_a?(Array)
53
- current_nodes[current_node_index] = request_options[method].map{|val| current_node.lookup[val] ||= RequestNode.new}
54
- else
55
- current_nodes[current_node_index] = (current_node.lookup[request_options[method]] ||= RequestNode.new)
56
- end
57
- when 1 #this node is farther ahead
58
- current_nodes[current_node_index] = (current_node.catchall ||= RequestNode.new)
59
- redo
60
- when -1 #this method is more important than the current node
61
- new_node = RequestNode.new
62
- new_node.request_method = method
63
- new_node.catchall = current_node
64
- current_nodes[current_node_index] = new_node
65
- redo
41
+ if !options.empty?
42
+ generate_request_method_tree(options)
43
+ elsif @request_node
44
+ current_node = @request_node
45
+ while current_node.request_method
46
+ current_node = (current_node.catchall ||= RequestNode.new)
47
+ end
48
+ [current_node]
49
+ else
50
+ [self]
51
+ end
52
+ end
53
+
54
+ protected
55
+
56
+ def transplant_value
57
+ if @value
58
+ target_node = @request_node
59
+ while target_node.request_method
60
+ target_node = (target_node.catchall ||= RequestNode.new)
61
+ end
62
+ target_node.value = @value
63
+ @value = nil
64
+ end
65
+ end
66
+
67
+ def generate_request_method_tree(request_options)
68
+ raise if (request_options.keys & RequestNode::RequestMethods).size != request_options.size
69
+
70
+
71
+ current_nodes = [@request_node ||= RequestNode.new]
72
+ RequestNode::RequestMethods.each do |method|
73
+ current_nodes.each_with_index do |current_node, current_node_index|
74
+ if request_options[method] #we care about the method
75
+ if current_node # and we have to pay attention to what currently is there.
76
+ unless current_node.request_method
77
+ current_node.request_method = method
78
+ end
79
+
80
+ case RequestNode::RequestMethods.index(method) <=> RequestNode::RequestMethods.index(current_node.request_method)
81
+ when 0 #use this node
82
+ if request_options[method].is_a?(Regexp)
83
+ current_node = RequestNode.new
84
+ current_node.linear << [request_options[method], current_node]
85
+ elsif request_options[method].is_a?(Array)
86
+ current_nodes[current_node_index] = request_options[method].map{|val| current_node.lookup[val] ||= RequestNode.new}
87
+ else
88
+ current_nodes[current_node_index] = (current_node.lookup[request_options[method]] ||= RequestNode.new)
66
89
  end
67
- else
68
- current_nodes[current_node_index] = RequestNode.new
90
+ when 1 #this node is farther ahead
91
+ current_nodes[current_node_index] = (current_node.catchall ||= RequestNode.new)
92
+ redo
93
+ when -1 #this method is more important than the current node
94
+ new_node = RequestNode.new
95
+ new_node.request_method = method
96
+ new_node.catchall = current_node
97
+ current_nodes[current_node_index] = new_node
69
98
  redo
70
99
  end
71
- elsif !current_node
72
- @request_node = RequestNode.new
73
- current_nodes[current_node_index] = @request_node
74
- redo
75
100
  else
76
- current_node.catchall ||= RequestNode.new
101
+ current_nodes[current_node_index] = RequestNode.new
102
+ redo
77
103
  end
104
+ elsif !current_node
105
+ @request_node = RequestNode.new
106
+ current_nodes[current_node_index] = @request_node
107
+ redo
108
+ else
109
+ current_node.catchall ||= RequestNode.new
78
110
  end
79
- current_nodes.flatten!
80
111
  end
81
- if @value
82
- target_node = @request_node
83
- while target_node.request_method
84
- target_node = (target_node.catchall ||= RequestNode.new)
112
+ current_nodes.flatten!
113
+ end
114
+ transplant_value
115
+ current_nodes
116
+ end
117
+
118
+ def find_on_request_methods(request)
119
+ current_node = self
120
+ if current_node && current_node.request_node
121
+ current_node = current_node.request_node
122
+ while current_node
123
+ previous_node = current_node
124
+ break if current_node.nil? || current_node.is_a?(RoutingError) || current_node.value
125
+ request_value = request.send(current_node.request_method)
126
+ unless current_node.linear.empty?
127
+ next_node = current_node.linear.find do |(regexp, node)|
128
+ regexp === request_value
129
+ end
130
+ if next_node
131
+ current_node = next_node.last
132
+ next
133
+ end
134
+ end
135
+ current_node = current_node.lookup[request_value] || current_node.catchall
136
+ if current_node.nil?
137
+ current_node = previous_node.request_method == :request_method ? RoutingError.new(405, {"Allow" => previous_node.lookup.keys.join(", ")}) : nil
138
+ else
139
+ current_node
85
140
  end
86
- target_node.value = @value
87
- @value = nil
88
- end
89
- current_nodes
90
- elsif @request_node
91
- current_node = @request_node
92
- while current_node.request_method
93
- current_node = (current_node.catchall ||= RequestNode.new)
94
141
  end
95
- [current_node]
96
- else
97
- [self]
98
142
  end
143
+ current_node
99
144
  end
145
+
100
146
  end
101
147
 
102
148
  class RequestNode < Node
@@ -1,10 +1,11 @@
1
+ require 'cgi'
1
2
  class HttpRouter
2
3
  class Path
3
4
  attr_reader :parts, :extension
4
5
  attr_accessor :route
5
6
  def initialize(path, parts, extension)
6
7
  @path, @parts, @extension = path, parts, extension
7
- @eval_path = path.gsub(/[:\*]([a-zA-Z0-9_]+)/) {"\#{args.shift || (options && options[:#{$1}]) || raise(MissingParameterException.new(\"missing parameter #{$1}\"))}" }
8
+ @eval_path = path.gsub(/[:\*]([a-zA-Z0-9_]+)/) {"\#{args.shift || (options && options.delete(:#{$1})) || raise(MissingParameterException.new(\"missing parameter #{$1}\"))}" }
8
9
  instance_eval "
9
10
  def raw_url(args,options)
10
11
  \"#{@eval_path}\"
@@ -16,9 +17,25 @@ class HttpRouter
16
17
  path = raw_url(args, options)
17
18
  raise TooManyParametersException.new unless args.empty?
18
19
  Rack::Utils.uri_escape!(path)
20
+ generate_querystring(path, options)
19
21
  path
20
22
  end
21
23
 
24
+ def generate_querystring(uri, params)
25
+ if params && !params.empty?
26
+ uri_size = uri.size
27
+ params.each do |k,v|
28
+ case v
29
+ when Array
30
+ v.each { |v_part| uri << '&' << CGI.escape(k.to_s) << '%5B%5D=' << CGI.escape(v_part.to_s) }
31
+ else
32
+ uri << '&' << CGI.escape(k.to_s) << '=' << CGI.escape(v.to_s)
33
+ end
34
+ end
35
+ uri[uri_size] = ??
36
+ end
37
+ end
38
+
22
39
  def variables
23
40
  unless @variables
24
41
  @variables = @parts.select{|p| p.is_a?(Variable)}
@@ -28,7 +45,7 @@ class HttpRouter
28
45
  end
29
46
 
30
47
  def variable_names
31
- variables.map{|v| v.name}
48
+ @variable_names ||= variables.map{|v| v.name}
32
49
  end
33
50
 
34
51
  def matches_extension?(extension)
@@ -13,6 +13,11 @@ class HttpRouter
13
13
  path.route
14
14
  end
15
15
 
16
+ def dest
17
+ route.dest
18
+ end
19
+ alias_method :destination, :dest
20
+
16
21
  def partial_match?
17
22
  remaining_path
18
23
  end
@@ -5,17 +5,38 @@ class HttpRouter
5
5
  reset!
6
6
  end
7
7
 
8
+ def add_path(path)
9
+ node = path.parts.inject(self) { |node, part| node.add(part) }
10
+ if path.extension
11
+ node = node.add_extension(path.extension)
12
+ end
13
+ node
14
+ end
15
+
8
16
  def find(request)
9
17
  path = request.path_info.dup
10
18
  path.slice!(-1) if @base.ignore_trailing_slash? && path[-1] == ?/
11
- path.gsub!(/\.([^\/\.]+)$/, '')
12
- extension = $1
19
+ extension = extract_extension(path)
13
20
  parts = @base.split(path)
14
21
  parts << '' if path[path.size - 1] == ?/
15
22
 
16
- current_node = self
17
23
  params = []
18
- while current_node
24
+ if current_node = process_parts(parts, extension, params)
25
+ current_node = current_node.find_on_request_methods(request)
26
+ end
27
+
28
+ process_response(current_node, parts, extension, params, request)
29
+ end
30
+
31
+ private
32
+
33
+ def process_parts(parts, extension, params)
34
+ current_node = self
35
+ loop do
36
+ if parts.empty? && current_node.extension_node && extension
37
+ parts << extension
38
+ current_node = current_node.extension_node
39
+ end
19
40
  break if current_node.nil? || (current_node.value && current_node.value.route.partially_match?) || parts.empty?
20
41
  unless current_node.linear.empty?
21
42
  whole_path = parts.join('/')
@@ -49,41 +70,21 @@ class HttpRouter
49
70
  break
50
71
  else
51
72
  current_node = nil
73
+ break
52
74
  end
53
75
  end
54
-
55
- if current_node && current_node.request_node
56
- current_node = current_node.request_node
57
- while current_node
58
- previous_node = current_node
59
- break if current_node.nil? || current_node.is_a?(RoutingError) || current_node.value
60
- request_value = request.send(current_node.request_method)
61
- unless current_node.linear.empty?
62
- next_node = current_node.linear.find do |(regexp, node)|
63
- regexp === request_value
64
- end
65
- if next_node
66
- current_node = next_node.last
67
- next
68
- end
69
- end
70
- current_node = current_node.lookup[request_value] || current_node.catchall
71
- if current_node.nil?
72
- current_node = previous_node.request_method == :request_method ? RoutingError.new(405, {"Allow" => previous_node.lookup.keys.join(", ")}) : nil
73
- else
74
- current_node
75
- end
76
- end
77
- end
78
- if current_node.is_a?(RoutingError)
79
- current_node
80
- elsif current_node && current_node.value
76
+ current_node
77
+ end
78
+
79
+ def process_response(node, parts, extension, params, request)
80
+ if node.is_a?(RoutingError)
81
+ node
82
+ elsif node && node.value
81
83
  if parts.empty?
82
- post_match(current_node.value, params, extension, request.path_info)
83
- elsif current_node.value.route.partially_match?
84
+ post_match(node.value, params, extension, request.path_info)
85
+ elsif node.value.route.partially_match?
84
86
  rest = '/' << parts.join('/') << (extension ? ".#{extension}" : '')
85
-
86
- post_match(current_node.value, params, nil, request.path_info[0, request.path_info.size - rest.size], rest)
87
+ post_match(node.value, params, nil, request.path_info[0, request.path_info.size - rest.size], rest)
87
88
  else
88
89
  nil
89
90
  end
@@ -92,6 +93,14 @@ class HttpRouter
92
93
  end
93
94
  end
94
95
 
96
+ def extract_extension(path)
97
+ if path.gsub!(/\.([^\/\.]+)$/, '')
98
+ extension = $1
99
+ else
100
+ nil
101
+ end
102
+ end
103
+
95
104
  def post_match(path, params, extension, matched_path, remaining_path = nil)
96
105
  if path.route.partially_match? || path.matches_extension?(extension)
97
106
  Response.new(path, params, extension, matched_path, remaining_path)
@@ -3,14 +3,72 @@ class HttpRouter
3
3
  attr_reader :dest, :paths
4
4
  attr_accessor :trailing_slash_ignore, :partially_match, :default_values
5
5
 
6
- def initialize(base, default_values)
7
- @base, @default_values = base, default_values
8
- @paths = []
6
+ def initialize(base, path)
7
+ @base = base
8
+ @path = path
9
+ @original_path = path.dup
10
+ @partially_match = extract_partial_match(path)
11
+ @trailing_slash_ignore = extract_trailing_slash(path)
12
+ @variable_store = {}
13
+ @matches_with = {}
14
+ @conditions = {}
15
+ end
16
+
17
+ def method_missing(method, *args, &block)
18
+ if RequestNode::RequestMethods.include?(method)
19
+ condition(method => args)
20
+ else
21
+ super
22
+ end
9
23
  end
10
24
 
11
25
  def name(name)
12
26
  @name = name
13
- @base.routes[name] = self
27
+ @base.named_routes[name] = self
28
+ end
29
+
30
+ def get
31
+ request_method('GET', 'HEAD')
32
+ end
33
+
34
+ def post
35
+ request_method('POST')
36
+ end
37
+
38
+ def head
39
+ request_method('head')
40
+ end
41
+
42
+ def put
43
+ request_method('PUT')
44
+ end
45
+
46
+ def delete
47
+ request_method('DELETE')
48
+ end
49
+
50
+ def only_get
51
+ request_method('DELETE')
52
+ end
53
+
54
+ def condition(conditions)
55
+ guard_compiled
56
+ conditions.each do |k,v|
57
+ @conditions.key?(k) ?
58
+ @conditions[k] << v :
59
+ @conditions[k] = Array(v)
60
+ end
61
+ self
62
+ end
63
+ alias_method :conditions, :condition
64
+
65
+ def matching(*match)
66
+ guard_compiled
67
+ @matches_with.merge!(match.pop) if match.last.is_a?(Hash)
68
+ match.each_slice(2) do |(k,v)|
69
+ @matches_with[k] = v
70
+ end
71
+ self
14
72
  end
15
73
 
16
74
  def named
@@ -18,19 +76,38 @@ class HttpRouter
18
76
  end
19
77
 
20
78
  def to(dest = nil, &block)
79
+ compile
21
80
  @dest = dest || block
22
81
  self
23
82
  end
24
83
 
25
- def match_partially!(match = true)
84
+ def partial(match = true)
26
85
  @partially_match = match
27
86
  self
28
87
  end
29
88
 
30
- def redirect(path, status = 302)
31
- unless (300..399).include?(status)
32
- raise ArgumentError, "Status has to be an integer between 300 and 399"
89
+ def compiled?
90
+ !@paths.nil?
91
+ end
92
+
93
+ def compile
94
+ unless @paths
95
+ @paths = compile_paths
96
+ @paths.each do |path|
97
+ path.route = self
98
+ current_node = @base.root.add_path(path)
99
+ working_set = current_node.add_request_methods(@conditions)
100
+ working_set.each do |current_node|
101
+ current_node.value = path
102
+ end
103
+ end
33
104
  end
105
+ self
106
+ end
107
+
108
+ def redirect(path, status = 302)
109
+ guard_compiled
110
+ raise(ArgumentError, "Status has to be an integer between 300 and 399") unless (300..399).include?(status)
34
111
  to { |env|
35
112
  params = env['router.params']
36
113
  response = ::Rack::Response.new
@@ -40,10 +117,11 @@ class HttpRouter
40
117
  self
41
118
  end
42
119
 
43
- def serves_static_from(root)
120
+ def static(root)
121
+ guard_compiled
122
+ raise AlreadyCompiledException.new if compiled?
44
123
  if File.directory?(root)
45
- match_partially!
46
- to ::Rack::File.new(root)
124
+ partial.to ::Rack::File.new(root)
47
125
  else
48
126
  to proc{|env| env['PATH_INFO'] = File.basename(root); ::Rack::File.new(File.dirname(root)).call(env)}
49
127
  end
@@ -60,11 +138,14 @@ class HttpRouter
60
138
 
61
139
  def url(*args)
62
140
  options = args.last.is_a?(Hash) ? args.pop : nil
141
+ options = default_values.merge(options) if default_values && options
63
142
  path = matching_path(args.empty? ? options : args)
64
143
  raise UngeneratableRouteException.new unless path
65
144
  path.url(args, options)
66
145
  end
67
146
 
147
+ private
148
+
68
149
  def matching_path(params)
69
150
  if @paths.size == 1
70
151
  @paths.first
@@ -77,26 +158,113 @@ class HttpRouter
77
158
  end
78
159
  nil
79
160
  else
80
- maximum_matched_route = nil
81
- maximum_matched_params = -1
82
- @paths.each do |path|
83
- param_count = 0
84
- path.variables.each do |variable|
85
- if params && params.key?(variable.name)
86
- param_count += 1
87
- else
88
- param_count = -1
89
- break
90
- end
161
+ @paths.reverse_each do |path|
162
+ if params && !params.empty?
163
+ return path if (path.variable_names & params.keys).size == path.variable_names.size
164
+ elsif path.variable_names.empty?
165
+ return path
166
+ end
167
+ end
168
+ nil
169
+ end
170
+ end
171
+ end
172
+
173
+ def extract_partial_match(path)
174
+ path[-1] == ?* && path.slice!(-1)
175
+ end
176
+
177
+ def extract_trailing_slash(path)
178
+ path[-2, 2] == '/?' && path.slice!(-2, 2)
179
+ end
180
+
181
+ def extract_extension(path)
182
+ if match = path.match(/^(.*)(\.:([a-zA-Z_]+))$/)
183
+ path.replace(match[1])
184
+ Variable.new(@base, match[3].to_sym)
185
+ elsif match = path.match(/^(.*)(\.([a-zA-Z_]+))$/)
186
+ path.replace(match[1])
187
+ match[3]
188
+ end
189
+ end
190
+
191
+
192
+ def compile_optionals(path)
193
+ start_index = 0
194
+ end_index = 1
195
+
196
+ paths = [""]
197
+ chars = path.split('')
198
+
199
+ chars.each do |c|
200
+ case c
201
+ when '('
202
+ # over current working set, double paths
203
+ (start_index...end_index).each do |path_index|
204
+ paths << paths[path_index].dup
205
+ end
206
+ start_index = end_index
207
+ end_index = paths.size
208
+ when ')'
209
+ start_index -= end_index - start_index
210
+ else
211
+ (start_index...end_index).each do |path_index|
212
+ paths[path_index] << c
91
213
  end
92
- if (param_count != -1 && param_count > maximum_matched_params)
93
- maximum_matched_params = param_count;
94
- maximum_matched_route = path;
214
+ end
215
+ end
216
+ paths
217
+ end
218
+
219
+ def compile_paths
220
+ paths = compile_optionals(@path)
221
+ paths.map do |path|
222
+ original_path = path.dup
223
+ extension = extract_extension(path)
224
+ new_path = @base.split(path).map do |part|
225
+ case part[0]
226
+ when ?:
227
+ v_name = part[1, part.size].to_sym
228
+ @variable_store[v_name] ||= Variable.new(@base, v_name, @matches_with[v_name])
229
+ when ?*
230
+ v_name = part[1, part.size].to_sym
231
+ @variable_store[v_name] ||= Glob.new(@base, v_name, @matches_with[v_name])
232
+ else
233
+ generate_interstitial_parts(part)
234
+ end
235
+ end
236
+ new_path.flatten!
237
+ Path.new(original_path, new_path, extension)
238
+ end
239
+ end
240
+
241
+ def generate_interstitial_parts(part)
242
+ part_segments = part.split(/(:[a-zA-Z_]+)/)
243
+ if part_segments.size > 1
244
+ index = 0
245
+ part_segments.map do |seg|
246
+ new_seg = if seg[0] == ?:
247
+ next_index = index + 1
248
+ scan_regex = if next_index == part_segments.size
249
+ /^[^\/]+/
250
+ else
251
+ /^.*?(?=#{Regexp.quote(part_segments[next_index])})/
95
252
  end
253
+ v_name = seg[1, seg.size].to_sym
254
+ @variable_store[v_name] ||= Variable.new(@base, v_name, scan_regex)
255
+ else
256
+ /^#{Regexp.quote(seg)}/
96
257
  end
97
- maximum_matched_route
258
+ index += 1
259
+ new_seg
98
260
  end
261
+ else
262
+ part
99
263
  end
100
264
  end
265
+
266
+ def guard_compiled
267
+ raise AlreadyCompiledException.new if compiled?
268
+ end
101
269
  end
102
270
  end
@@ -60,9 +60,6 @@ class HttpRouter
60
60
 
61
61
  def route(verb, path, options={}, &block)
62
62
  name = options.delete(:name)
63
- options[:conditions] ||= {}
64
- options[:conditions][:request_method] = verb
65
- options[:conditions][:host] = options.delete(:host) if options.key?(:host)
66
63
 
67
64
  define_method "#{verb} #{path}", &block
68
65
  unbound_method = instance_method("#{verb} #{path}")
@@ -75,7 +72,11 @@ class HttpRouter
75
72
 
76
73
  invoke_hook(:route_added, verb, path, block)
77
74
 
78
- route = router.add(path, options).to(block)
75
+ route = router.add(path)
76
+ route.request_method(verb)
77
+ route.host(options.delete(:host)) if options.key?(:host)
78
+
79
+ route.to(block)
79
80
  route.name(name) if name
80
81
  route
81
82
  end
data/lib/http_router.rb CHANGED
@@ -14,15 +14,18 @@ class HttpRouter
14
14
  UngeneratableRouteException = Class.new(RuntimeError)
15
15
  MissingParameterException = Class.new(RuntimeError)
16
16
  TooManyParametersException = Class.new(RuntimeError)
17
+ AlreadyCompiledException = Class.new(RuntimeError)
17
18
  RoutingError = Struct.new(:status, :headers)
18
19
 
19
- attr_reader :routes
20
+ attr_reader :named_routes, :routes, :root
20
21
 
21
22
  def initialize(options = nil)
22
- reset!
23
23
  @default_app = options && options[:default_app] || proc{|env| ::Rack::Response.new("Not Found", 404).finish }
24
24
  @ignore_trailing_slash = options && options.key?(:ignore_trailing_slash) ? options[:ignore_trailing_slash] : true
25
25
  @redirect_trailing_slash = options && options.key?(:redirect_trailing_slash) ? options[:redirect_trailing_slash] : false
26
+ @routes = []
27
+ @named_routes = {}
28
+ reset!
26
29
  end
27
30
 
28
31
  def ignore_trailing_slash?
@@ -35,7 +38,8 @@ class HttpRouter
35
38
 
36
39
  def reset!
37
40
  @root = Root.new(self)
38
- @routes = {}
41
+ @routes.clear
42
+ @named_routes.clear
39
43
  end
40
44
 
41
45
  def default(app)
@@ -47,150 +51,44 @@ class HttpRouter
47
51
  with_delimiter ? path.split('(/)') : path.split('/')
48
52
  end
49
53
 
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
54
+ def add(path)
55
+ route = Route.new(self, path.dup)
56
+ @routes << route
69
57
  route
70
58
  end
71
59
 
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)
60
+ def get(path)
61
+ add(path).get
88
62
  end
89
63
 
90
- def delete(path, options = {})
91
- options[:conditions] ||= {}
92
- options[:conditions][:request_method] = 'DELETE'
93
- add(path, options)
64
+ def post(path)
65
+ add(path).post
94
66
  end
95
67
 
96
- def only_get(path, options = {})
97
- options[:conditions] ||= {}
98
- options[:conditions][:request_method] = "GET"
99
- add(path, options)
68
+ def put(path)
69
+ add(path).put
100
70
  end
101
71
 
102
- def extract_partial_match(path)
103
- if path[-1] == ?*
104
- path.slice!(-1)
105
- true
106
- else
107
- false
108
- end
72
+ def delete(path)
73
+ add(path).delete
109
74
  end
110
75
 
111
- def extract_trailing_slash(path)
112
- if path[-2, 2] == '/?'
113
- path.slice!(-2, 2)
114
- true
115
- else
116
- false
117
- end
76
+ def only_get(path)
77
+ add(path).only_get
118
78
  end
119
79
 
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
80
+ def recognize(env)
81
+ response = @root.find(env.is_a?(Hash) ? Rack::Request.new(env) : env)
128
82
  end
129
83
 
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)
84
+ def url(route, *args)
85
+ case route
86
+ when Symbol
87
+ url(@named_routes[route], *args)
88
+ when nil
89
+ raise UngeneratableRouteException.new
90
+ else
91
+ route.url(*args)
194
92
  end
195
93
  end
196
94
 
@@ -208,10 +106,6 @@ class HttpRouter
208
106
  elsif response && response.route.dest && response.route.dest.respond_to?(:call)
209
107
  process_params(env, response)
210
108
  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
109
  response.route.dest.call(env)
216
110
  else
217
111
  @default_app.call(env)
@@ -219,6 +113,8 @@ class HttpRouter
219
113
  end
220
114
  end
221
115
 
116
+ private
117
+
222
118
  def consume_path!(request, response)
223
119
  request.env["SCRIPT_NAME"] = (request.env["SCRIPT_NAME"] + response.matched_path)
224
120
  request.env["PATH_INFO"] = response.remaining_path || ""
@@ -233,18 +129,4 @@ class HttpRouter
233
129
  end
234
130
  end
235
131
 
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
132
  end
@@ -6,7 +6,7 @@ describe "HttpRouter#generate" do
6
6
  context("static paths") do
7
7
  ['/', '/test', '/test/time', '/one/more/what', '/test.html'].each do |path|
8
8
  it "should generate #{path.inspect}" do
9
- route = @router.add(path)
9
+ route = @router.add(path).compile
10
10
  @router.url(route).should == path
11
11
  end
12
12
  end
@@ -14,34 +14,44 @@ describe "HttpRouter#generate" do
14
14
 
15
15
  context("dynamic paths") do
16
16
  it "should generate from a hash" do
17
- @router.add("/:var").name(:test)
17
+ @router.add("/:var").name(:test).compile
18
18
  @router.url(:test, :var => 'test').should == '/test'
19
19
  end
20
20
 
21
+ it "should generate from a hash with extra parts going to the query string" do
22
+ @router.add("/:var").name(:test).compile
23
+ @router.url(:test, :var => 'test', :query => 'string').should == '/test?query=string'
24
+ end
25
+
21
26
  it "should generate from an array" do
22
- @router.add("/:var").name(:test)
27
+ @router.add("/:var").name(:test).compile
23
28
  @router.url(:test, 'test').should == '/test'
24
29
  end
25
30
 
31
+ it "should generate from an array with extra parts going to the query string" do
32
+ @router.add("/:var").name(:test).compile
33
+ @router.url(:test, 'test', :query => 'string').should == '/test?query=string'
34
+ end
35
+
26
36
  it "should generate with a format" do
27
- @router.add("/test.:format").name(:test)
37
+ @router.add("/test.:format").name(:test).compile
28
38
  @router.url(:test, 'html').should == '/test.html'
29
39
  end
30
40
 
31
41
  it "should generate with a format as a hash" do
32
- @router.add("/test.:format").name(:test)
42
+ @router.add("/test.:format").name(:test).compile
33
43
  @router.url(:test, :format => 'html').should == '/test.html'
34
44
  end
35
45
 
36
46
  it "should generate with an optional format" do
37
- @router.add("/test(.:format)").name(:test)
47
+ @router.add("/test(.:format)").name(:test).compile
38
48
  @router.url(:test, 'html').should == '/test.html'
39
49
  @router.url(:test).should == '/test'
40
50
  end
41
51
 
42
52
  context "with optional parts" do
43
53
  it "should generate both" do
44
- @router.add("/:var1(/:var2)").name(:test)
54
+ @router.add("/:var1(/:var2)").name(:test).compile
45
55
  @router.url(:test, 'var').should == '/var'
46
56
  @router.url(:test, 'var', 'fooz').should == '/var/fooz'
47
57
  @router.url(:test, :var1 => 'var').should == '/var'
@@ -25,7 +25,7 @@ describe "Usher (for rack) route dispatching" do
25
25
  describe "HTTP GET" do
26
26
  before(:each) do
27
27
  route_set.reset!
28
- route_set.add('/sample', :conditions => {:request_method => 'GET'}).to(@app)
28
+ route_set.add('/sample').request_method('GET').to(@app)
29
29
  end
30
30
 
31
31
  it "should dispatch a request" do
@@ -42,7 +42,7 @@ describe "Usher (for rack) route dispatching" do
42
42
  describe "HTTP POST" do
43
43
  before(:each) do
44
44
  route_set.reset!
45
- route_set.add('/sample', :conditions => {:request_method => 'POST'}).to(@app)
45
+ route_set.add('/sample').post.to(@app)
46
46
  route_set.add('/sample').to(MockApp.new("You shouldn't get here if you are using POST"))
47
47
  end
48
48
 
@@ -64,8 +64,8 @@ describe "Usher (for rack) route dispatching" do
64
64
 
65
65
  it "should returns HTTP 405 if the method mis-matches" do
66
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)
67
+ route_set.post('/sample').to(@app)
68
+ route_set.put('/sample').to(@app)
69
69
  response = route_set.call_with_mock_request('/sample', 'GET')
70
70
  response.status.should eql(405)
71
71
  response['Allow'].should == 'POST, PUT'
@@ -5,9 +5,9 @@ describe "Usher (for rack) route generation" do
5
5
  before(:each) do
6
6
  route_set.reset!
7
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)
8
+ route_set.add("/fixed").name(:fixed).compile
9
+ route_set.add("/named/simple/:named_simple_var").name(:simple).compile
10
+ route_set.add("/named/optional(/:named_optional_var)").name(:optional).compile
11
11
  end
12
12
 
13
13
  describe "named routes" do
@@ -29,14 +29,14 @@ describe "Rack interface extensions for Usher::Route" do
29
29
 
30
30
  describe "static file serving" do
31
31
  it "should serve from a static directory" do
32
- @route_set.get("/static").serves_static_from(File.dirname(__FILE__))
32
+ @route_set.get("/static").static(File.dirname(__FILE__))
33
33
  @env = Rack::MockRequest.env_for("/static/#{File.basename(__FILE__)}")
34
34
  status, headers, body = @route_set.call(@env)
35
35
  body.path.should == File.join(File.dirname(__FILE__), File.basename(__FILE__))
36
36
  end
37
37
 
38
38
  it "should serve a specific file" do
39
- @route_set.get("/static-file").serves_static_from(__FILE__)
39
+ @route_set.get("/static-file").static(__FILE__)
40
40
  @env = Rack::MockRequest.env_for("/static-file")
41
41
  status, headers, body = @route_set.call(@env)
42
42
  body.path.should == __FILE__
@@ -6,14 +6,14 @@ describe "HttpRouter#recognize" do
6
6
  context("static paths") do
7
7
  ['/', '/test', '/test/time', '/one/more/what', '/test.html'].each do |path|
8
8
  it "should recognize #{path.inspect}" do
9
- route = @router.add(path)
9
+ route = @router.add(path).to(path)
10
10
  @router.recognize(Rack::MockRequest.env_for(path)).route.should == route
11
11
  end
12
12
  end
13
13
 
14
14
  context("with optional parts") do
15
15
  it "work either way" do
16
- route = @router.add("/test(/optional)")
16
+ route = @router.add("/test(/optional)").to(:test)
17
17
  @router.recognize(Rack::MockRequest.env_for('/test')).route.should == route
18
18
  @router.recognize(Rack::MockRequest.env_for('/test/optional')).route.should == route
19
19
  end
@@ -21,7 +21,7 @@ describe "HttpRouter#recognize" do
21
21
 
22
22
  context("partial matching") do
23
23
  it "should match partially or completely" do
24
- route = @router.add("/test*")
24
+ route = @router.add("/test*").to(:test)
25
25
  @router.recognize(Rack::MockRequest.env_for('/test')).route.should == route
26
26
  response = @router.recognize(Rack::MockRequest.env_for('/test/optional'))
27
27
  response.route.should == route
@@ -31,19 +31,19 @@ describe "HttpRouter#recognize" do
31
31
 
32
32
  context("trailing slashes") do
33
33
  it "should ignore a trailing slash" do
34
- route = @router.add("/test")
34
+ route = @router.add("/test").to(:test)
35
35
  @router.recognize(Rack::MockRequest.env_for('/test/')).route.should == route
36
36
  end
37
37
 
38
38
  it "should not recognize a trailing slash when used with the /? syntax and ignore_trailing_slash disabled" do
39
39
  @router = HttpRouter.new(:ignore_trailing_slash => false)
40
- route = @router.add("/test/?")
40
+ route = @router.add("/test/?").to(:test)
41
41
  @router.recognize(Rack::MockRequest.env_for('/test/')).route.should == route
42
42
  end
43
43
 
44
44
  it "should recognize a trailing slash when used with the /? syntax and ignore_trailing_slash enabled" do
45
45
  @router = HttpRouter.new(:ignore_trailing_slash => false)
46
- route = @router.add("/test")
46
+ route = @router.add("/test").to(:test)
47
47
  @router.recognize(Rack::MockRequest.env_for('/test/')).should be_nil
48
48
  end
49
49
  end
@@ -52,30 +52,30 @@ describe "HttpRouter#recognize" do
52
52
 
53
53
  context "request methods" do
54
54
  it "should pick a specific request_method" do
55
- route = @router.add("/test", :conditions => {:request_method => 'POST'})
55
+ route = @router.post("/test").to(:test)
56
56
  @router.recognize(Rack::MockRequest.env_for('/test', :method => 'POST')).route.should == route
57
57
  @router.recognize(Rack::MockRequest.env_for('/test', :method => 'GET')).status.should == 405
58
58
  @router.recognize(Rack::MockRequest.env_for('/test', :method => 'GET')).headers['Allow'].should == "POST"
59
59
  end
60
60
 
61
61
  it "should pick a specific request_method with other paths all through it" do
62
- @router.add("/test", :conditions => {:request_method => 'POST'}).name(:test_post)
63
- @router.add("/test/post", :conditions => {:request_method => 'POST'}).name(:test_post_post)
64
- @router.add("/test", :conditions => {:request_method => 'GET'}).name(:test_get)
65
- @router.add("/test/post", :conditions => {:request_method => 'GET'}).name(:test_post_get)
66
- @router.add("/test/post").name(:test_post_catchall)
67
- @router.add("/test").name(:test_catchall)
68
- @router.recognize(Rack::MockRequest.env_for('/test', :method => 'POST')).route.named.should == :test_post
69
- @router.recognize(Rack::MockRequest.env_for('/test', :method => 'GET')).route.named.should == :test_get
70
- @router.recognize(Rack::MockRequest.env_for('/test', :method => 'PUT')).route.named.should == :test_catchall
71
- @router.recognize(Rack::MockRequest.env_for('/test/post', :method => 'POST')).route.named.should == :test_post_post
72
- @router.recognize(Rack::MockRequest.env_for('/test/post', :method => 'GET')).route.named.should == :test_post_get
73
- @router.recognize(Rack::MockRequest.env_for('/test/post', :method => 'PUT')).route.named.should == :test_post_catchall
62
+ @router.post("/test").to(:test_post)
63
+ @router.post("/test/post").to(:test_post_post)
64
+ @router.get("/test").to(:test_get)
65
+ @router.get("/test/post").to(:test_post_get)
66
+ @router.add("/test/post").to(:test_post_catchall)
67
+ @router.add("/test").to(:test_catchall)
68
+ @router.recognize(Rack::MockRequest.env_for('/test', :method => 'POST')).dest.should == :test_post
69
+ @router.recognize(Rack::MockRequest.env_for('/test', :method => 'GET')).dest.should == :test_get
70
+ @router.recognize(Rack::MockRequest.env_for('/test', :method => 'PUT')).dest.should == :test_catchall
71
+ @router.recognize(Rack::MockRequest.env_for('/test/post', :method => 'POST')).dest.should == :test_post_post
72
+ @router.recognize(Rack::MockRequest.env_for('/test/post', :method => 'GET')).dest.should == :test_post_get
73
+ @router.recognize(Rack::MockRequest.env_for('/test/post', :method => 'PUT')).dest.should == :test_post_catchall
74
74
  end
75
75
 
76
76
  it "should move an endpoint to the non-specific request method when a more specific route gets added" do
77
- @router.add("/test").name(:test_catchall)
78
- @router.add("/test", :conditions => {:request_method => 'POST'}).name(:test_post)
77
+ @router.add("/test").name(:test_catchall).to(:test1)
78
+ @router.post("/test").request_method('POST').name(:test_post).to(:test2)
79
79
  @router.recognize(Rack::MockRequest.env_for('/test', :method => 'POST')).route.named.should == :test_post
80
80
  @router.recognize(Rack::MockRequest.env_for('/test', :method => 'PUT')).route.named.should == :test_catchall
81
81
  end
@@ -84,7 +84,7 @@ describe "HttpRouter#recognize" do
84
84
 
85
85
  context("dynamic paths") do
86
86
  it "should recognize '/:variable'" do
87
- route = @router.add('/:variable')
87
+ route = @router.add('/:variable').to(:test)
88
88
  response = @router.recognize(Rack::MockRequest.env_for('/value'))
89
89
  response.route.should == route
90
90
  response.params.should == ['value']
@@ -92,15 +92,16 @@ describe "HttpRouter#recognize" do
92
92
  end
93
93
 
94
94
  it "should recognize '/test.:format'" do
95
- route = @router.add('/test.:format')
95
+ route = @router.add('/test.:format').to(:test)
96
96
  response = @router.recognize(Rack::MockRequest.env_for('/test.html'))
97
97
  response.route.should == route
98
98
  response.extension.should == 'html'
99
99
  response.params_as_hash[:format].should == 'html'
100
+ @router.recognize(Rack::MockRequest.env_for('/test')).should be_nil
100
101
  end
101
102
 
102
103
  it "should recognize '/test(.:format)'" do
103
- route = @router.add('/test(.:format)')
104
+ route = @router.add('/test(.:format)').to(:test)
104
105
  response = @router.recognize(Rack::MockRequest.env_for('/test.html'))
105
106
  response.route.should == route
106
107
  response.extension.should == 'html'
@@ -112,7 +113,7 @@ describe "HttpRouter#recognize" do
112
113
  end
113
114
 
114
115
  it "should recognize '/:test.:format'" do
115
- route = @router.add('/:test.:format')
116
+ route = @router.add('/:test.:format').to(:test)
116
117
  response = @router.recognize(Rack::MockRequest.env_for('/hey.html'))
117
118
  response.route.should == route
118
119
  response.extension.should == 'html'
@@ -121,7 +122,7 @@ describe "HttpRouter#recognize" do
121
122
  end
122
123
 
123
124
  it "should recognize '/:test(.:format)'" do
124
- route = @router.add('/:test(.:format)')
125
+ route = @router.add('/:test(.:format)').to(:test)
125
126
  response = @router.recognize(Rack::MockRequest.env_for('/hey.html'))
126
127
  response.route.should == route
127
128
  response.extension.should == 'html'
@@ -136,13 +137,13 @@ describe "HttpRouter#recognize" do
136
137
 
137
138
  context "globs" do
138
139
  it "should recognize a glob" do
139
- route = @router.add('/test/*variable')
140
+ route = @router.add('/test/*variable').to(:test)
140
141
  response = @router.recognize(Rack::MockRequest.env_for('/test/one/two/three'))
141
142
  response.route.should == route
142
143
  response.params.should == [['one', 'two', 'three']]
143
144
  end
144
145
  it "should recognize a glob with a regexp" do
145
- route = @router.add('/test/*variable/anymore', :matches_with => {:variable => /^\d+$/})
146
+ route = @router.add('/test/*variable/anymore').matching(:variable => /^\d+$/).to(:test)
146
147
  response = @router.recognize(Rack::MockRequest.env_for('/test/123/345/567/anymore'))
147
148
  response.route.should == route
148
149
  response.params.should == [['123', '345', '567']]
@@ -155,7 +156,7 @@ describe "HttpRouter#recognize" do
155
156
 
156
157
  context("interstitial variables") do
157
158
  it "should recognize interstitial variables" do
158
- route = @router.add('/one-:variable-time')
159
+ route = @router.add('/one-:variable-time').to(:test)
159
160
  response = @router.recognize(Rack::MockRequest.env_for('/one-value-time'))
160
161
  response.route.should == route
161
162
  response.params_as_hash[:variable].should == 'value'
@@ -164,7 +165,7 @@ describe "HttpRouter#recognize" do
164
165
 
165
166
  context("dynamic greedy paths") do
166
167
  it "should recognize greedy variables" do
167
- route = @router.add('/:variable', :matches_with => { :variable => /\d+/})
168
+ route = @router.add('/:variable').matching(:variable, /\d+/).to(:test)
168
169
  response = @router.recognize(Rack::MockRequest.env_for('/123'))
169
170
  response.route.should == route
170
171
  response.params.should == ['123']
metadata CHANGED
@@ -4,9 +4,9 @@ version: !ruby/object:Gem::Version
4
4
  prerelease: false
5
5
  segments:
6
6
  - 0
7
- - 0
8
7
  - 1
9
- version: 0.0.1
8
+ - 0
9
+ version: 0.1.0
10
10
  platform: ruby
11
11
  authors:
12
12
  - Joshua Hull
@@ -14,7 +14,7 @@ autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
16
 
17
- date: 2010-05-25 00:00:00 -04:00
17
+ date: 2010-05-27 00:00:00 -04:00
18
18
  default_executable:
19
19
  dependencies: []
20
20