http_router 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,60 @@
1
+ = HTTP Router
2
+
3
+ == Introduction
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.
6
+
7
+ == Features
8
+
9
+ * Supports variables, and globbing.
10
+ * Regex support for variables.
11
+ * Request condition support.
12
+ * Partial matches.
13
+ * Supports interstitial variables (e.g. /my-:variable-brings.all.the.boys/yard).
14
+ * Very fast and small code base (~600 loc).
15
+ * Sinatra compatibility.
16
+
17
+ == Usage
18
+
19
+ === <tt>HttpRouter.new</tt>
20
+
21
+ Takes the following options:
22
+
23
+ * <tt>:default_app</tt> - The default #call made on non-matches. Defaults to a 404 generator.
24
+ * <tt>:ignore_trailing_slash</tt> - Ignores the trailing slash when matching. Defaults to true.
25
+ * <tt>:redirect_trailing_slash</tt> - Redirect on trailing slash matches to non-trailing slash paths. Defaults to false.
26
+
27
+ === <tt>#add(name, options)</tt>
28
+
29
+ Maps a route. The format for variables in paths is:
30
+ :variable
31
+ *glob
32
+
33
+ Everything else is treated literally. Optional parts are surrounded by brackets. Partially matching paths have a trailing <tt>*</tt>. Optional trailing slash matching is done with <tt>/?</tt>.
34
+
35
+ Once you have a route object, use <tt>HttpRouter::Route#to</tt> to add a destination and <tt>HttpRouter::Route#name</tt> to name it.
36
+
37
+ e.g.
38
+
39
+ r = HttpRouter.new
40
+ 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')
43
+
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>.
46
+
47
+ There are convenience methods HttpRouter#get, HttpRouter#post, etc for each request method.
48
+
49
+ === <tt>#url(name or route, *args)</tt>
50
+
51
+ Generates a route. The args can either be a hash, a list, or a mix of both.
52
+
53
+ === <tt>#call(env or Rack::Request)</tt>
54
+
55
+ Recognizes and dispatches the request.
56
+
57
+ === <tt>#recognize(env or Rack::Request)</tt>
58
+
59
+ Only performs recognition.
60
+
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ begin
2
+ require 'jeweler'
3
+ Jeweler::Tasks.new do |s|
4
+ s.name = "http_router"
5
+ s.description = s.summary = "A kick-ass HTTP router for use in Rack & Sinatra"
6
+ s.email = "joshbuddy@gmail.com"
7
+ s.homepage = "http://github.com/joshbuddy/http_router"
8
+ s.authors = ["Joshua Hull"]
9
+ s.files = FileList["[A-Z]*", "{lib,spec}/**/*"]
10
+ end
11
+ Jeweler::GemcutterTasks.new
12
+ rescue LoadError
13
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
14
+ end
15
+
16
+ require 'spec'
17
+ require 'spec/rake/spectask'
18
+ Spec::Rake::SpecTask.new(:spec) do |t|
19
+ t.spec_opts ||= []
20
+ t.ruby_opts << "-rrubygems"
21
+ t.ruby_opts << "-Ilib"
22
+ t.ruby_opts << "-rhttp_router"
23
+ t.ruby_opts << "-rspec/spec_helper"
24
+ t.spec_opts << "--options" << "spec/spec.opts"
25
+ t.spec_files = FileList['spec/**/*_spec.rb']
26
+ end
27
+
28
+ begin
29
+ require 'code_stats'
30
+ CodeStats::Tasks.new
31
+ rescue LoadError
32
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,18 @@
1
+ class HttpRouter
2
+ class Glob < Variable
3
+ def matches(parts, whole_path)
4
+ if @matches_with && match = @matches_with.match(parts.first)
5
+ params = [parts.shift]
6
+ while !parts.empty? and match = @matches_with.match(parts.first)
7
+ params << parts.shift
8
+ end
9
+ whole_path.replace(parts.join('/'))
10
+ params
11
+ else
12
+ params = parts.dup
13
+ parts.clear
14
+ params
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,107 @@
1
+ class HttpRouter
2
+ class Node
3
+ attr_accessor :value, :variable, :catchall, :request_node
4
+ attr_reader :linear, :lookup
5
+
6
+ def initialize
7
+ reset!
8
+ end
9
+
10
+ def reset!
11
+ @linear = []
12
+ @lookup = {}
13
+ @catchall = nil
14
+ end
15
+
16
+ def add(val)
17
+ if val.is_a?(Variable)
18
+ if val.matches_with
19
+ new_node = Node.new
20
+ @linear << [val, new_node]
21
+ new_node
22
+ else
23
+ @catchall ||= Node.new
24
+ @catchall.variable = val
25
+ @catchall
26
+ end
27
+ elsif val.is_a?(Regexp)
28
+ @linear << [val, Node.new]
29
+ @linear.last.last
30
+ else
31
+ @lookup[val] ||= Node.new
32
+ end
33
+ end
34
+
35
+ 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
66
+ end
67
+ else
68
+ current_nodes[current_node_index] = RequestNode.new
69
+ redo
70
+ end
71
+ elsif !current_node
72
+ @request_node = RequestNode.new
73
+ current_nodes[current_node_index] = @request_node
74
+ redo
75
+ else
76
+ current_node.catchall ||= RequestNode.new
77
+ end
78
+ end
79
+ current_nodes.flatten!
80
+ end
81
+ if @value
82
+ target_node = @request_node
83
+ while target_node.request_method
84
+ target_node = (target_node.catchall ||= RequestNode.new)
85
+ 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
+ end
95
+ [current_node]
96
+ else
97
+ [self]
98
+ end
99
+ end
100
+ end
101
+
102
+ class RequestNode < Node
103
+ RequestMethods = [:request_method, :host, :port, :scheme]
104
+ attr_accessor :request_method
105
+ end
106
+
107
+ end
@@ -0,0 +1,38 @@
1
+ class HttpRouter
2
+ class Path
3
+ attr_reader :parts, :extension
4
+ attr_accessor :route
5
+ def initialize(path, parts, extension)
6
+ @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
+ instance_eval "
9
+ def raw_url(args,options)
10
+ \"#{@eval_path}\"
11
+ end
12
+ "
13
+ end
14
+
15
+ def url(args, options)
16
+ path = raw_url(args, options)
17
+ raise TooManyParametersException.new unless args.empty?
18
+ Rack::Utils.uri_escape!(path)
19
+ path
20
+ end
21
+
22
+ def variables
23
+ unless @variables
24
+ @variables = @parts.select{|p| p.is_a?(Variable)}
25
+ @variables << @extension if @extension.is_a?(Variable)
26
+ end
27
+ @variables
28
+ end
29
+
30
+ def variable_names
31
+ variables.map{|v| v.name}
32
+ end
33
+
34
+ def matches_extension?(extension)
35
+ @extension.nil? || @extension === (extension)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ class HttpRouter
2
+ class Response < Struct.new(:path, :params, :extension, :matched_path, :remaining_path)
3
+ attr_reader :params_as_hash, :route
4
+
5
+ def initialize(path, params, extension, matched_path, remaining_path)
6
+ raise if matched_path.nil?
7
+ super
8
+ @params_as_hash = path.variable_names.zip(params).inject({}) {|h, (k,v)| h[k] = v; h }
9
+ @params_as_hash[path.extension.name] = extension if path.extension && path.extension.is_a?(Variable)
10
+ end
11
+
12
+ def route
13
+ path.route
14
+ end
15
+
16
+ def partial_match?
17
+ remaining_path
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,103 @@
1
+ class HttpRouter
2
+ class Root < Node
3
+ def initialize(base)
4
+ @base = base
5
+ reset!
6
+ end
7
+
8
+ def find(request)
9
+ path = request.path_info.dup
10
+ path.slice!(-1) if @base.ignore_trailing_slash? && path[-1] == ?/
11
+ path.gsub!(/\.([^\/\.]+)$/, '')
12
+ extension = $1
13
+ parts = @base.split(path)
14
+ parts << '' if path[path.size - 1] == ?/
15
+
16
+ current_node = self
17
+ params = []
18
+ while current_node
19
+ break if current_node.nil? || (current_node.value && current_node.value.route.partially_match?) || parts.empty?
20
+ unless current_node.linear.empty?
21
+ whole_path = parts.join('/')
22
+ next_node = current_node.linear.find do |(tester, node)|
23
+ if tester.is_a?(Regexp) and match = whole_path.match(tester)
24
+ whole_path.slice!(0,match[0].size)
25
+ parts.replace(@base.split(whole_path))
26
+ node
27
+ elsif new_params = tester.matches(parts, whole_path)
28
+ params << new_params
29
+ node
30
+ else
31
+ nil
32
+ end
33
+ end
34
+ if next_node
35
+ current_node = next_node.last
36
+ next
37
+ end
38
+ end
39
+ if match = current_node.lookup[parts.first]
40
+ parts.shift
41
+ current_node = match
42
+ elsif current_node.catchall
43
+ params << current_node.catchall.variable.matches(parts, whole_path)
44
+ parts.shift
45
+ current_node = current_node.catchall
46
+ elsif parts.size == 1 && parts.first == '' && current_node && (current_node.value && current_node.value.route.trailing_slash_ignore?)
47
+ parts.shift
48
+ elsif current_node.request_node
49
+ break
50
+ else
51
+ current_node = nil
52
+ end
53
+ 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
81
+ if parts.empty?
82
+ post_match(current_node.value, params, extension, request.path_info)
83
+ elsif current_node.value.route.partially_match?
84
+ 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
+ else
88
+ nil
89
+ end
90
+ else
91
+ nil
92
+ end
93
+ end
94
+
95
+ def post_match(path, params, extension, matched_path, remaining_path = nil)
96
+ if path.route.partially_match? || path.matches_extension?(extension)
97
+ Response.new(path, params, extension, matched_path, remaining_path)
98
+ else
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,102 @@
1
+ class HttpRouter
2
+ class Route
3
+ attr_reader :dest, :paths
4
+ attr_accessor :trailing_slash_ignore, :partially_match, :default_values
5
+
6
+ def initialize(base, default_values)
7
+ @base, @default_values = base, default_values
8
+ @paths = []
9
+ end
10
+
11
+ def name(name)
12
+ @name = name
13
+ @base.routes[name] = self
14
+ end
15
+
16
+ def named
17
+ @name
18
+ end
19
+
20
+ def to(dest = nil, &block)
21
+ @dest = dest || block
22
+ self
23
+ end
24
+
25
+ def match_partially!(match = true)
26
+ @partially_match = match
27
+ self
28
+ end
29
+
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"
33
+ end
34
+ to { |env|
35
+ params = env['router.params']
36
+ response = ::Rack::Response.new
37
+ response.redirect(eval(%|"#{path}"|), status)
38
+ response.finish
39
+ }
40
+ self
41
+ end
42
+
43
+ def serves_static_from(root)
44
+ if File.directory?(root)
45
+ match_partially!
46
+ to ::Rack::File.new(root)
47
+ else
48
+ to proc{|env| env['PATH_INFO'] = File.basename(root); ::Rack::File.new(File.dirname(root)).call(env)}
49
+ end
50
+ self
51
+ end
52
+
53
+ def trailing_slash_ignore?
54
+ @trailing_slash_ignore
55
+ end
56
+
57
+ def partially_match?
58
+ @partially_match
59
+ end
60
+
61
+ def url(*args)
62
+ options = args.last.is_a?(Hash) ? args.pop : nil
63
+ path = matching_path(args.empty? ? options : args)
64
+ raise UngeneratableRouteException.new unless path
65
+ path.url(args, options)
66
+ end
67
+
68
+ def matching_path(params)
69
+ if @paths.size == 1
70
+ @paths.first
71
+ else
72
+ if params.is_a?(Array)
73
+ @paths.each do |path|
74
+ if path.variables.size == params.size
75
+ return path
76
+ end
77
+ end
78
+ nil
79
+ 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
91
+ end
92
+ if (param_count != -1 && param_count > maximum_matched_params)
93
+ maximum_matched_params = param_count;
94
+ maximum_matched_route = path;
95
+ end
96
+ end
97
+ maximum_matched_route
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,149 @@
1
+ $LOAD_PATH << File.join(File.dirname(__FILE__), '..')
2
+ require 'http_router'
3
+
4
+ class HttpRouter
5
+ module Interface
6
+ class Sinatra
7
+
8
+ def initialize
9
+ ::Sinatra.send(:include, Extension)
10
+ end
11
+
12
+ module Extension
13
+
14
+ def self.registered(app)
15
+ app.send(:include, Extension)
16
+ end
17
+
18
+ def self.included(base)
19
+ base.extend ClassMethods
20
+ end
21
+
22
+ def generate(name, *params)
23
+ self.class.generate(name, *params)
24
+ end
25
+
26
+ private
27
+ def route!(base=self.class, pass_block=nil)
28
+ if base.router and match = base.router.recognize(@request)
29
+ if match.is_a?(RoutingError)
30
+ route_eval {
31
+ match.headers.each{|k,v| response[k] = v}
32
+ status match.status
33
+ }
34
+ else
35
+ @block_params = match.params
36
+ (@params ||= {}).merge!(match.params_as_hash)
37
+ pass_block = catch(:pass) do
38
+ route_eval(&match.route.dest)
39
+ end
40
+ end
41
+ end
42
+
43
+ # Run routes defined in superclass.
44
+ if base.superclass.respond_to?(:router)
45
+ route! base.superclass, pass_block
46
+ return
47
+ end
48
+
49
+ route_eval(&pass_block) if pass_block
50
+
51
+ route_missing
52
+ end
53
+
54
+ module ClassMethods
55
+
56
+ def new(*args, &bk)
57
+ configure! unless @_configured
58
+ super(*args, &bk)
59
+ end
60
+
61
+ def route(verb, path, options={}, &block)
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
+
67
+ define_method "#{verb} #{path}", &block
68
+ unbound_method = instance_method("#{verb} #{path}")
69
+ block =
70
+ if block.arity != 0
71
+ lambda { unbound_method.bind(self).call(*@block_params) }
72
+ else
73
+ lambda { unbound_method.bind(self).call }
74
+ end
75
+
76
+ invoke_hook(:route_added, verb, path, block)
77
+
78
+ route = router.add(path, options).to(block)
79
+ route.name(name) if name
80
+ route
81
+ end
82
+
83
+ def router
84
+ @router ||= HttpRouter.new
85
+ block_given? ? yield(@router) : @router
86
+ end
87
+
88
+ def generate(name, *params)
89
+ router.url(name, *params)
90
+ end
91
+
92
+ def reset!
93
+ router.reset!
94
+ super
95
+ end
96
+
97
+ def configure!
98
+ configure :development do
99
+ error 404 do
100
+ content_type 'text/html'
101
+
102
+ (<<-HTML).gsub(/^ {17}/, '')
103
+ <!DOCTYPE html>
104
+ <html>
105
+ <head>
106
+ <style type="text/css">
107
+ body { text-align:center;font-family:helvetica,arial;font-size:22px;
108
+ color:#888;margin:20px}
109
+ #c {margin:0 auto;width:500px;text-align:left}
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <h2>Sinatra doesn't know this ditty.</h2>
114
+ <div id="c">
115
+ Try this:
116
+ <pre>#{request.request_method.downcase} '#{request.path_info}' do\n "Hello World"\nend</pre>
117
+ </div>
118
+ </body>
119
+ </html>
120
+ HTML
121
+ end
122
+ error 405 do
123
+ content_type 'text/html'
124
+
125
+ (<<-HTML).gsub(/^ {17}/, '')
126
+ <!DOCTYPE html>
127
+ <html>
128
+ <head>
129
+ <style type="text/css">
130
+ body { text-align:center;font-family:helvetica,arial;font-size:22px;
131
+ color:#888;margin:20px}
132
+ #c {margin:0 auto;width:500px;text-align:left}
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <h2>Sinatra sorta knows this ditty, but the request method is not allowed.</h2>
137
+ </body>
138
+ </html>
139
+ HTML
140
+ end
141
+ end
142
+
143
+ @_configured = true
144
+ end
145
+ end # ClassMethods
146
+ end # Extension
147
+ end # Sinatra
148
+ end # Interface
149
+ end # HttpRouter
@@ -0,0 +1,26 @@
1
+ class HttpRouter
2
+ class Variable
3
+ attr_reader :name, :matches_with
4
+
5
+ def initialize(base, name, matches_with = nil)
6
+ @base = base
7
+ @name = name
8
+ @matches_with = matches_with
9
+ end
10
+
11
+ def matches(parts, whole_path)
12
+ if @matches_with.nil?
13
+ parts.first
14
+ elsif @matches_with && match = @matches_with.match(whole_path)
15
+ whole_path.slice!(0, match[0].size)
16
+ parts.replace(@base.split(whole_path))
17
+ match[0]
18
+ end
19
+ end
20
+
21
+ def ===(part)
22
+ @matches_with.nil?
23
+ end
24
+
25
+ end
26
+ end