http_router 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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