journey 1.0.0.rc1
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/.autotest +8 -0
- data/.gemtest +0 -0
- data/CHANGELOG.rdoc +6 -0
- data/Gemfile +11 -0
- data/Manifest.txt +50 -0
- data/README.rdoc +48 -0
- data/Rakefile +31 -0
- data/journey.gemspec +42 -0
- data/lib/journey.rb +5 -0
- data/lib/journey/backwards.rb +5 -0
- data/lib/journey/core-ext/hash.rb +11 -0
- data/lib/journey/formatter.rb +129 -0
- data/lib/journey/gtg/builder.rb +159 -0
- data/lib/journey/gtg/simulator.rb +44 -0
- data/lib/journey/gtg/transition_table.rb +152 -0
- data/lib/journey/nfa/builder.rb +74 -0
- data/lib/journey/nfa/dot.rb +34 -0
- data/lib/journey/nfa/simulator.rb +45 -0
- data/lib/journey/nfa/transition_table.rb +164 -0
- data/lib/journey/nodes/node.rb +104 -0
- data/lib/journey/parser.rb +204 -0
- data/lib/journey/parser.y +47 -0
- data/lib/journey/parser_extras.rb +21 -0
- data/lib/journey/path/pattern.rb +190 -0
- data/lib/journey/route.rb +92 -0
- data/lib/journey/router.rb +138 -0
- data/lib/journey/router/strexp.rb +22 -0
- data/lib/journey/router/utils.rb +57 -0
- data/lib/journey/routes.rb +74 -0
- data/lib/journey/scanner.rb +58 -0
- data/lib/journey/visitors.rb +186 -0
- data/lib/journey/visualizer/d3.min.js +2 -0
- data/lib/journey/visualizer/fsm.css +34 -0
- data/lib/journey/visualizer/fsm.js +134 -0
- data/lib/journey/visualizer/index.html.erb +50 -0
- data/lib/journey/visualizer/reset.css +48 -0
- data/test/gtg/test_builder.rb +77 -0
- data/test/gtg/test_transition_table.rb +113 -0
- data/test/helper.rb +4 -0
- data/test/nfa/test_simulator.rb +96 -0
- data/test/nfa/test_transition_table.rb +70 -0
- data/test/nodes/test_symbol.rb +15 -0
- data/test/path/test_pattern.rb +260 -0
- data/test/route/definition/test_parser.rb +108 -0
- data/test/route/definition/test_scanner.rb +52 -0
- data/test/router/test_strexp.rb +30 -0
- data/test/router/test_utils.rb +19 -0
- data/test/test_route.rb +95 -0
- data/test/test_router.rb +464 -0
- data/test/test_routes.rb +51 -0
- metadata +168 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
module Journey
|
2
|
+
class Route
|
3
|
+
attr_reader :app, :path, :verb, :defaults, :ip, :name
|
4
|
+
|
5
|
+
attr_reader :constraints
|
6
|
+
alias :conditions :constraints
|
7
|
+
|
8
|
+
##
|
9
|
+
# +path+ is a path constraint.
|
10
|
+
# +constraints+ is a hash of constraints to be applied to this route.
|
11
|
+
def initialize name, app, path, constraints, defaults = {}
|
12
|
+
constraints = constraints.dup
|
13
|
+
@name = name
|
14
|
+
@app = app
|
15
|
+
@path = path
|
16
|
+
@verb = constraints[:request_method] || //
|
17
|
+
@ip = constraints.delete(:ip) || //
|
18
|
+
|
19
|
+
@constraints = constraints
|
20
|
+
@constraints.keep_if { |_,v| Regexp === v || String === v }
|
21
|
+
@defaults = defaults
|
22
|
+
@required_defaults = nil
|
23
|
+
@required_parts = nil
|
24
|
+
@parts = nil
|
25
|
+
@decorated_ast = nil
|
26
|
+
end
|
27
|
+
|
28
|
+
def ast
|
29
|
+
return @decorated_ast if @decorated_ast
|
30
|
+
|
31
|
+
@decorated_ast = path.ast
|
32
|
+
@decorated_ast.grep(Nodes::Terminal).each { |n| n.memo = self }
|
33
|
+
@decorated_ast
|
34
|
+
end
|
35
|
+
|
36
|
+
def requirements # :nodoc:
|
37
|
+
# needed for rails `rake routes`
|
38
|
+
path.requirements.merge(@defaults).delete_if { |_,v|
|
39
|
+
/.+?/ == v
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def segments
|
44
|
+
@path.names
|
45
|
+
end
|
46
|
+
|
47
|
+
def required_keys
|
48
|
+
path.required_names.map { |x| x.to_sym } + required_defaults.keys
|
49
|
+
end
|
50
|
+
|
51
|
+
def score constraints
|
52
|
+
required_keys = path.required_names
|
53
|
+
supplied_keys = constraints.map { |k,v| v && k.to_s }.compact
|
54
|
+
|
55
|
+
return -1 unless (required_keys - supplied_keys).empty?
|
56
|
+
|
57
|
+
score = (supplied_keys & path.names).length
|
58
|
+
score + (required_defaults.length * 2)
|
59
|
+
end
|
60
|
+
|
61
|
+
def parts
|
62
|
+
@parts ||= segments.map { |n| n.to_sym }
|
63
|
+
end
|
64
|
+
alias :segment_keys :parts
|
65
|
+
|
66
|
+
def format path_options
|
67
|
+
(defaults.keys - required_parts).each do |key|
|
68
|
+
path_options.delete key if defaults[key].to_s == path_options[key].to_s
|
69
|
+
end
|
70
|
+
|
71
|
+
formatter = Visitors::Formatter.new(path_options)
|
72
|
+
|
73
|
+
formatted_path = formatter.accept(path.spec)
|
74
|
+
formatted_path.gsub(/\/\x00/, '')
|
75
|
+
end
|
76
|
+
|
77
|
+
def optional_parts
|
78
|
+
path.optional_names.map { |n| n.to_sym }
|
79
|
+
end
|
80
|
+
|
81
|
+
def required_parts
|
82
|
+
@required_parts ||= path.required_names.map { |n| n.to_sym }
|
83
|
+
end
|
84
|
+
|
85
|
+
def required_defaults
|
86
|
+
@required_defaults ||= begin
|
87
|
+
matches = parts
|
88
|
+
@defaults.dup.delete_if { |k,_| matches.include? k }
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'journey/core-ext/hash'
|
2
|
+
require 'journey/router/utils'
|
3
|
+
require 'journey/router/strexp'
|
4
|
+
require 'journey/routes'
|
5
|
+
require 'journey/formatter'
|
6
|
+
|
7
|
+
before = $-w
|
8
|
+
$-w = false
|
9
|
+
require 'journey/parser'
|
10
|
+
$-w = before
|
11
|
+
|
12
|
+
require 'journey/route'
|
13
|
+
require 'journey/path/pattern'
|
14
|
+
|
15
|
+
module Journey
|
16
|
+
class Router
|
17
|
+
class RoutingError < ::StandardError
|
18
|
+
end
|
19
|
+
|
20
|
+
VERSION = '1.0.0.rc1'
|
21
|
+
|
22
|
+
class NullReq # :nodoc:
|
23
|
+
attr_reader :env
|
24
|
+
def initialize env
|
25
|
+
@env = env
|
26
|
+
end
|
27
|
+
|
28
|
+
def request_method
|
29
|
+
env['REQUEST_METHOD']
|
30
|
+
end
|
31
|
+
|
32
|
+
def [](k); env[k]; end
|
33
|
+
end
|
34
|
+
|
35
|
+
attr_reader :request_class, :formatter
|
36
|
+
attr_accessor :routes
|
37
|
+
|
38
|
+
def initialize routes, options
|
39
|
+
@options = options
|
40
|
+
@params_key = options[:parameters_key]
|
41
|
+
@request_class = options[:request_class] || NullReq
|
42
|
+
@routes = routes
|
43
|
+
end
|
44
|
+
|
45
|
+
def call env
|
46
|
+
env['PATH_INFO'] = Utils.normalize_path env['PATH_INFO']
|
47
|
+
|
48
|
+
find_routes(env).each do |match, parameters, route|
|
49
|
+
script_name, path_info, set_params = env.values_at('SCRIPT_NAME',
|
50
|
+
'PATH_INFO',
|
51
|
+
@params_key)
|
52
|
+
|
53
|
+
unless route.path.anchored
|
54
|
+
env['SCRIPT_NAME'] = script_name.to_s + match.to_s
|
55
|
+
env['PATH_INFO'] = match.post_match
|
56
|
+
end
|
57
|
+
|
58
|
+
env[@params_key] = (set_params || {}).merge parameters
|
59
|
+
|
60
|
+
status, headers, body = route.app.call(env)
|
61
|
+
|
62
|
+
if 'pass' == headers['X-Cascade']
|
63
|
+
env['SCRIPT_NAME'] = script_name
|
64
|
+
env['PATH_INFO'] = path_info
|
65
|
+
env[@params_key] = set_params
|
66
|
+
next
|
67
|
+
end
|
68
|
+
|
69
|
+
return [status, headers, body]
|
70
|
+
end
|
71
|
+
|
72
|
+
return [404, {'X-Cascade' => 'pass'}, ['Not Found']]
|
73
|
+
end
|
74
|
+
|
75
|
+
def recognize req
|
76
|
+
find_routes(req.env).each do |match, parameters, route|
|
77
|
+
unless route.path.anchored
|
78
|
+
req.env['SCRIPT_NAME'] = match.to_s
|
79
|
+
req.env['PATH_INFO'] = match.post_match.sub(/^([^\/])/, '/\1')
|
80
|
+
end
|
81
|
+
|
82
|
+
yield(route, nil, parameters)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def visualizer
|
87
|
+
tt = GTG::Builder.new(ast).transition_table
|
88
|
+
groups = partitioned_routes.first.map(&:ast).group_by { |a| a.to_s }
|
89
|
+
asts = groups.values.map { |v| v.first }
|
90
|
+
tt.visualizer asts
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def partitioned_routes
|
96
|
+
routes.partitioned_routes
|
97
|
+
end
|
98
|
+
|
99
|
+
def ast
|
100
|
+
routes.ast
|
101
|
+
end
|
102
|
+
|
103
|
+
def simulator
|
104
|
+
routes.simulator
|
105
|
+
end
|
106
|
+
|
107
|
+
def custom_routes
|
108
|
+
partitioned_routes.last
|
109
|
+
end
|
110
|
+
|
111
|
+
def filter_routes path
|
112
|
+
return [] unless ast
|
113
|
+
data = simulator.match(path)
|
114
|
+
data ? data.memos : []
|
115
|
+
end
|
116
|
+
|
117
|
+
def find_routes env
|
118
|
+
addr = env['REMOTE_ADDR']
|
119
|
+
req = request_class.new env
|
120
|
+
|
121
|
+
routes = filter_routes(env['PATH_INFO']) + custom_routes.find_all { |r|
|
122
|
+
r.path.match(env['PATH_INFO'])
|
123
|
+
}
|
124
|
+
|
125
|
+
routes.find_all { |r|
|
126
|
+
r.constraints.all? { |k,v| v === req.send(k) } &&
|
127
|
+
r.verb === env['REQUEST_METHOD']
|
128
|
+
}.reject { |r| addr && !(r.ip === addr) }.map { |r|
|
129
|
+
match_data = r.path.match(env['PATH_INFO'])
|
130
|
+
match_names = match_data.names.map { |n| n.to_sym }
|
131
|
+
match_values = match_data.captures.map { |v| v && Utils.unescape_uri(v) }
|
132
|
+
info = Hash[match_names.zip(match_values).find_all { |_,y| y }]
|
133
|
+
|
134
|
+
[match_data, r.defaults.merge(info), r]
|
135
|
+
}
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Journey
|
2
|
+
class Router
|
3
|
+
class Strexp
|
4
|
+
class << self
|
5
|
+
alias :compile :new
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :path, :requirements, :separators, :anchor
|
9
|
+
|
10
|
+
def initialize path, requirements, separators, anchor = true
|
11
|
+
@path = path
|
12
|
+
@requirements = requirements
|
13
|
+
@separators = separators
|
14
|
+
@anchor = anchor
|
15
|
+
end
|
16
|
+
|
17
|
+
def names
|
18
|
+
@path.scan(/:\w+/).map { |s| s.tr(':', '') }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
module Journey
|
4
|
+
class Router
|
5
|
+
class Utils
|
6
|
+
# Normalizes URI path.
|
7
|
+
#
|
8
|
+
# Strips off trailing slash and ensures there is a leading slash.
|
9
|
+
#
|
10
|
+
# normalize_path("/foo") # => "/foo"
|
11
|
+
# normalize_path("/foo/") # => "/foo"
|
12
|
+
# normalize_path("foo") # => "/foo"
|
13
|
+
# normalize_path("") # => "/"
|
14
|
+
def self.normalize_path(path)
|
15
|
+
path = "/#{path}"
|
16
|
+
path.squeeze!('/')
|
17
|
+
path.sub!(%r{/+\Z}, '')
|
18
|
+
path = '/' if path == ''
|
19
|
+
path
|
20
|
+
end
|
21
|
+
|
22
|
+
# URI path and fragment escaping
|
23
|
+
# http://tools.ietf.org/html/rfc3986
|
24
|
+
module UriEscape
|
25
|
+
# Symbol captures can generate multiple path segments, so include /.
|
26
|
+
reserved_segment = '/'
|
27
|
+
reserved_fragment = '/?'
|
28
|
+
reserved_pchar = ':@&=+$,;%'
|
29
|
+
|
30
|
+
safe_pchar = "#{URI::REGEXP::PATTERN::UNRESERVED}#{reserved_pchar}"
|
31
|
+
safe_segment = "#{safe_pchar}#{reserved_segment}"
|
32
|
+
safe_fragment = "#{safe_pchar}#{reserved_fragment}"
|
33
|
+
if RUBY_VERSION >= '1.9'
|
34
|
+
UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false).freeze
|
35
|
+
UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false).freeze
|
36
|
+
else
|
37
|
+
UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false, 'N').freeze
|
38
|
+
UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false, 'N').freeze
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
Parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI
|
43
|
+
|
44
|
+
def self.escape_path(path)
|
45
|
+
Parser.escape(path.to_s, UriEscape::UNSAFE_SEGMENT)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.escape_fragment(fragment)
|
49
|
+
Parser.escape(fragment.to_s, UriEscape::UNSAFE_FRAGMENT)
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.unescape_uri(uri)
|
53
|
+
Parser.unescape(uri)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Journey
|
2
|
+
###
|
3
|
+
# The Routing table. Contains all routes for a system. Routes can be
|
4
|
+
# added to the table by calling Routes#add_route
|
5
|
+
class Routes
|
6
|
+
include Enumerable
|
7
|
+
|
8
|
+
attr_reader :routes, :named_routes
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@routes = []
|
12
|
+
@named_routes = {}
|
13
|
+
@ast = nil
|
14
|
+
@partitioned_routes = nil
|
15
|
+
@simulator = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def length
|
19
|
+
@routes.length
|
20
|
+
end
|
21
|
+
alias :size :length
|
22
|
+
|
23
|
+
def last
|
24
|
+
@routes.last
|
25
|
+
end
|
26
|
+
|
27
|
+
def each(&block)
|
28
|
+
routes.each(&block)
|
29
|
+
end
|
30
|
+
|
31
|
+
def clear
|
32
|
+
routes.clear
|
33
|
+
end
|
34
|
+
|
35
|
+
def partitioned_routes
|
36
|
+
@partitioned_routes ||= routes.partition { |r|
|
37
|
+
r.path.anchored && r.ast.grep(Nodes::Symbol).all? { |n| n.default_regexp? }
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def ast
|
42
|
+
return @ast if @ast
|
43
|
+
return if partitioned_routes.first.empty?
|
44
|
+
|
45
|
+
asts = partitioned_routes.first.map { |r| r.ast }
|
46
|
+
@ast = Nodes::Or.new(asts)
|
47
|
+
end
|
48
|
+
|
49
|
+
def simulator
|
50
|
+
return @simulator if @simulator
|
51
|
+
|
52
|
+
gtg = GTG::Builder.new(ast).transition_table
|
53
|
+
@simulator = GTG::Simulator.new gtg
|
54
|
+
end
|
55
|
+
|
56
|
+
###
|
57
|
+
# Add a route to the routing table.
|
58
|
+
def add_route app, path, conditions, defaults, name = nil
|
59
|
+
route = Route.new(name, app, path, conditions, defaults)
|
60
|
+
|
61
|
+
routes << route
|
62
|
+
named_routes[name] = route if name && !named_routes[name]
|
63
|
+
clear_cache!
|
64
|
+
route
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
def clear_cache!
|
69
|
+
@ast = nil
|
70
|
+
@partitioned_routes = nil
|
71
|
+
@simulator = nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
|
3
|
+
module Journey
|
4
|
+
class Scanner
|
5
|
+
def initialize
|
6
|
+
@ss = nil
|
7
|
+
end
|
8
|
+
|
9
|
+
def scan_setup str
|
10
|
+
@ss = StringScanner.new str
|
11
|
+
end
|
12
|
+
|
13
|
+
def eos?
|
14
|
+
@ss.eos?
|
15
|
+
end
|
16
|
+
|
17
|
+
def pos
|
18
|
+
@ss.pos
|
19
|
+
end
|
20
|
+
|
21
|
+
def pre_match
|
22
|
+
@ss.pre_match
|
23
|
+
end
|
24
|
+
|
25
|
+
def next_token
|
26
|
+
return if @ss.eos?
|
27
|
+
|
28
|
+
until token = scan || @ss.eos?; end
|
29
|
+
token
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
def scan
|
34
|
+
case
|
35
|
+
# /
|
36
|
+
when text = @ss.scan(/\//)
|
37
|
+
[:SLASH, text]
|
38
|
+
when text = @ss.scan(/\*/)
|
39
|
+
[:STAR, text]
|
40
|
+
when text = @ss.scan(/\(/)
|
41
|
+
[:LPAREN, text]
|
42
|
+
when text = @ss.scan(/\)/)
|
43
|
+
[:RPAREN, text]
|
44
|
+
when text = @ss.scan(/\|/)
|
45
|
+
[:OR, text]
|
46
|
+
when text = @ss.scan(/\./)
|
47
|
+
[:DOT, text]
|
48
|
+
when text = @ss.scan(/:\w+/)
|
49
|
+
[:SYMBOL, text]
|
50
|
+
when text = @ss.scan(/[\w-]+/)
|
51
|
+
[:LITERAL, text]
|
52
|
+
# any char
|
53
|
+
when text = @ss.scan(/./)
|
54
|
+
[:LITERAL, text]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|