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.
Files changed (51) hide show
  1. data/.autotest +8 -0
  2. data/.gemtest +0 -0
  3. data/CHANGELOG.rdoc +6 -0
  4. data/Gemfile +11 -0
  5. data/Manifest.txt +50 -0
  6. data/README.rdoc +48 -0
  7. data/Rakefile +31 -0
  8. data/journey.gemspec +42 -0
  9. data/lib/journey.rb +5 -0
  10. data/lib/journey/backwards.rb +5 -0
  11. data/lib/journey/core-ext/hash.rb +11 -0
  12. data/lib/journey/formatter.rb +129 -0
  13. data/lib/journey/gtg/builder.rb +159 -0
  14. data/lib/journey/gtg/simulator.rb +44 -0
  15. data/lib/journey/gtg/transition_table.rb +152 -0
  16. data/lib/journey/nfa/builder.rb +74 -0
  17. data/lib/journey/nfa/dot.rb +34 -0
  18. data/lib/journey/nfa/simulator.rb +45 -0
  19. data/lib/journey/nfa/transition_table.rb +164 -0
  20. data/lib/journey/nodes/node.rb +104 -0
  21. data/lib/journey/parser.rb +204 -0
  22. data/lib/journey/parser.y +47 -0
  23. data/lib/journey/parser_extras.rb +21 -0
  24. data/lib/journey/path/pattern.rb +190 -0
  25. data/lib/journey/route.rb +92 -0
  26. data/lib/journey/router.rb +138 -0
  27. data/lib/journey/router/strexp.rb +22 -0
  28. data/lib/journey/router/utils.rb +57 -0
  29. data/lib/journey/routes.rb +74 -0
  30. data/lib/journey/scanner.rb +58 -0
  31. data/lib/journey/visitors.rb +186 -0
  32. data/lib/journey/visualizer/d3.min.js +2 -0
  33. data/lib/journey/visualizer/fsm.css +34 -0
  34. data/lib/journey/visualizer/fsm.js +134 -0
  35. data/lib/journey/visualizer/index.html.erb +50 -0
  36. data/lib/journey/visualizer/reset.css +48 -0
  37. data/test/gtg/test_builder.rb +77 -0
  38. data/test/gtg/test_transition_table.rb +113 -0
  39. data/test/helper.rb +4 -0
  40. data/test/nfa/test_simulator.rb +96 -0
  41. data/test/nfa/test_transition_table.rb +70 -0
  42. data/test/nodes/test_symbol.rb +15 -0
  43. data/test/path/test_pattern.rb +260 -0
  44. data/test/route/definition/test_parser.rb +108 -0
  45. data/test/route/definition/test_scanner.rb +52 -0
  46. data/test/router/test_strexp.rb +30 -0
  47. data/test/router/test_utils.rb +19 -0
  48. data/test/test_route.rb +95 -0
  49. data/test/test_router.rb +464 -0
  50. data/test/test_routes.rb +51 -0
  51. 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