http_router 0.0.1 → 0.1.0

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