ruby_routes 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9f05718781115fc2cc1d8851ddea52d0af3fa46f7a937335398b518ea0116e9
4
- data.tar.gz: 12b51d5138dbbdec4b46d66f20489e167a721f67f58ed80652add4a2e9aa606b
3
+ metadata.gz: 2bc2a63fd1cf72287ce09440410f609579ef5a3cdb4a33513187599dcb5b9ed7
4
+ data.tar.gz: 560ab2b883aeb3e42f74ef20fd675e3a692173724a78451bd5739115803944cf
5
5
  SHA512:
6
- metadata.gz: 32c7fab4ab209b6c2c1a5d50531d9f248f8413fb1409c6ebec9173eb538149c2a5f358e6bad85c0ed6993ed6b91a62a46f8d3551881ffaba4551b80df79c13a2
7
- data.tar.gz: dd96665fc956cf1a76444e75287c2661b3629c0d0cc5546f44ed464533a50b8ff830e354d1caa83782c53855446f6e0043039ae09489fef8b5f33eeaa73329c0
6
+ metadata.gz: dc41f27e4af178dd3a76dbed95f8a2df9aed4a041d3af184b76ccb39480a9b7d7facab6e2e30147926a3ca178d6e45b391c942eb9cb6fbdfd2167c794ef7d573
7
+ data.tar.gz: eafd4d678f45780a7da280e8e91cfddb7f0abe21cadb248fc5f6517e3632c442d7602363b89685baacdff106588a88c1b62c0180670db681af4b7539cd66c1c2
@@ -0,0 +1,24 @@
1
+ module RubyRoutes
2
+ class Node
3
+ attr_accessor :static_children, :dynamic_child, :wildcard_child,
4
+ :handlers, :param_name, :is_endpoint
5
+
6
+ def initialize
7
+ @static_children = {}
8
+ @dynamic_child = nil
9
+ @wildcard_child = nil
10
+ @handlers = {}
11
+ @param_name = nil
12
+ @is_endpoint = false
13
+ end
14
+
15
+ def add_handler(method, handler)
16
+ @handlers[method.to_s] = handler
17
+ @is_endpoint = true
18
+ end
19
+
20
+ def get_handler(method)
21
+ @handlers[method.to_s]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,134 @@
1
+ module RubyRoutes
2
+ class RadixTree
3
+ class << self
4
+ # Allow RadixTree.new(path, options...) to act as a convenience factory
5
+ # returning a Route (this matches test usage where specs call
6
+ # RadixTree.new('/path', to: 'controller#action')).
7
+ # Calling RadixTree.new with no arguments returns an actual RadixTree instance.
8
+ def new(*args, &block)
9
+ if args.any?
10
+ # Delegate to Route initializer when args are provided
11
+ RubyRoutes::Route.new(*args, &block)
12
+ else
13
+ super()
14
+ end
15
+ end
16
+ end
17
+
18
+ def initialize
19
+ @root = Node.new
20
+ @_split_cache = {} # simple LRU: key -> [value, age]
21
+ @split_cache_order = [] # track order for eviction
22
+ @split_cache_max = 1024
23
+ end
24
+
25
+ def add(path, methods, handler)
26
+ segments = split_path(path)
27
+ current = @root
28
+
29
+ segments.each do |segment|
30
+ if segment.start_with?('*')
31
+ current.wildcard_child ||= Node.new
32
+ current = current.wildcard_child
33
+ current.param_name = segment[1..-1] || 'splat'
34
+ break
35
+ elsif segment.start_with?(':')
36
+ current.dynamic_child ||= Node.new
37
+ current = current.dynamic_child
38
+ current.param_name = segment[1..-1]
39
+ else
40
+ current.static_children[segment] ||= Node.new
41
+ current = current.static_children[segment]
42
+ end
43
+ end
44
+
45
+ methods.each { |method| current.add_handler(method, handler) }
46
+ end
47
+
48
+ def find(path, method)
49
+ segments = split_path(path)
50
+ current = @root
51
+ params = {}
52
+
53
+ segments.each_with_index do |segment, index|
54
+ if current.static_children.key?(segment)
55
+ current = current.static_children[segment]
56
+ elsif current.dynamic_child
57
+ current = current.dynamic_child
58
+ # keep string keys to avoid symbol allocations and extra conversions later
59
+ params[current.param_name.to_s] = segment
60
+ elsif current.wildcard_child
61
+ current = current.wildcard_child
62
+ params[current.param_name.to_s] = segments[index..-1].join('/')
63
+ break
64
+ else
65
+ return [nil, {}]
66
+ end
67
+ end
68
+
69
+ handler = current.get_handler(method)
70
+ return [nil, {}] unless current.is_endpoint && handler
71
+
72
+ # lightweight constraint checks: reject early if route constraints don't match
73
+ route = handler
74
+ if route.respond_to?(:constraints) && route.constraints.any?
75
+ unless constraints_match?(route.constraints, params)
76
+ return [nil, {}]
77
+ end
78
+ end
79
+
80
+ [handler, params]
81
+ end
82
+
83
+ private
84
+
85
+ # faster, lower-allocation trim + split
86
+ def split_path(path)
87
+ @split_cache ||= {}
88
+ return [''] if path == '/'
89
+ if (cached = @split_cache[path])
90
+ return cached
91
+ end
92
+
93
+ p = path
94
+ p = p[1..-1] if p.start_with?('/')
95
+ p = p[0...-1] if p.end_with?('/')
96
+ segs = p.split('/')
97
+
98
+ # simple LRU insert
99
+ @split_cache[path] = segs
100
+ @split_cache_order << path
101
+ if @split_cache_order.size > @split_cache_max
102
+ oldest = @split_cache_order.shift
103
+ @split_cache.delete(oldest)
104
+ end
105
+
106
+ segs
107
+ end
108
+
109
+ # constraints match helper (non-raising, lightweight)
110
+ def constraints_match?(constraints, params)
111
+ constraints.each do |param, constraint|
112
+ value = params[param.to_s] || params[param]
113
+ next unless value
114
+
115
+ case constraint
116
+ when Regexp
117
+ return false unless constraint.match?(value)
118
+ when Proc
119
+ return false unless constraint.call(value)
120
+ when Symbol
121
+ case constraint
122
+ when :int then return false unless value.match?(/^\d+$/)
123
+ when :uuid then return false unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
124
+ else
125
+ # unknown symbol constraint — be conservative and allow
126
+ end
127
+ else
128
+ # unknown constraint type — allow (Route will validate later if needed)
129
+ end
130
+ end
131
+ true
132
+ end
133
+ end
134
+ end
@@ -1,5 +1,43 @@
1
+ require 'uri'
2
+
1
3
  module RubyRoutes
2
4
  class Route
5
+ # small LRU used for path generation cache
6
+ class SmallLru
7
+ attr_reader :hits, :misses, :evictions
8
+
9
+ # larger default to reduce eviction likelihood in benchmarks
10
+ def initialize(max_size = 1024)
11
+ @max_size = max_size
12
+ @h = {}
13
+ @hits = 0
14
+ @misses = 0
15
+ @evictions = 0
16
+ end
17
+
18
+ def get(key)
19
+ if @h.key?(key)
20
+ @hits += 1
21
+ val = @h.delete(key)
22
+ @h[key] = val
23
+ val
24
+ else
25
+ @misses += 1
26
+ nil
27
+ end
28
+ end
29
+
30
+ def set(key, val)
31
+ @h.delete(key) if @h.key?(key)
32
+ @h[key] = val
33
+ if @h.size > @max_size
34
+ @h.shift
35
+ @evictions += 1
36
+ end
37
+ val
38
+ end
39
+ end
40
+
3
41
  attr_reader :path, :methods, :controller, :action, :name, :constraints, :defaults
4
42
 
5
43
  def initialize(path, options = {})
@@ -9,16 +47,15 @@ module RubyRoutes
9
47
  @action = options[:action] || extract_action(options[:to])
10
48
  @name = options[:as]
11
49
  @constraints = options[:constraints] || {}
12
- @defaults = options[:defaults] || {}
50
+ # pre-normalize defaults to string keys to avoid per-request transform_keys
51
+ @defaults = (options[:defaults] || {}).transform_keys(&:to_s)
13
52
 
14
53
  validate_route!
15
54
  end
16
55
 
17
56
  def match?(request_method, request_path)
18
57
  return false unless methods.include?(request_method.to_s.upcase)
19
-
20
- path_params = extract_path_params(request_path)
21
- path_params != nil
58
+ !!extract_path_params(request_path)
22
59
  end
23
60
 
24
61
  def extract_params(request_path)
@@ -26,9 +63,11 @@ module RubyRoutes
26
63
  return {} unless path_params
27
64
 
28
65
  params = path_params.dup
29
- # Convert symbol keys to string keys for consistency
30
- string_defaults = defaults.transform_keys(&:to_s)
31
- params.merge!(string_defaults)
66
+ params.merge!(query_params(request_path)) # query_params returns string-keyed hash below
67
+ # defaults already string keys, only merge keys that aren't present
68
+ defaults.each { |k, v| params[k] = v unless params.key?(k) }
69
+
70
+ validate_constraints!(params)
32
71
  params
33
72
  end
34
73
 
@@ -44,13 +83,100 @@ module RubyRoutes
44
83
  !resource?
45
84
  end
46
85
 
86
+ # Fast path generator: uses precompiled token list and a small LRU.
87
+ # Avoids unbounded cache growth and skips URI-encoding for safe values.
88
+ def generate_path(params = {})
89
+ return '/' if path == '/'
90
+
91
+ # build merged for only relevant param names
92
+ merged = {}
93
+ defaults.each { |k, v| merged[k] = v } # defaults already string keys
94
+ params.each do |k, v|
95
+ ks = k.to_s
96
+ merged[ks] = v
97
+ end
98
+
99
+ missing = compiled_required_params - merged.keys
100
+ raise RubyRoutes::RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
101
+
102
+ @gen_cache ||= SmallLru.new(256)
103
+ cache_key = cache_key_for(merged)
104
+ if (cached = @gen_cache.get(cache_key))
105
+ return cached
106
+ end
107
+
108
+ parts = compiled_segments.map do |seg|
109
+ case seg[:type]
110
+ when :static
111
+ seg[:value]
112
+ when :param
113
+ v = merged.fetch(seg[:name]).to_s
114
+ safe_encode_segment(v)
115
+ when :splat
116
+ v = merged.fetch(seg[:name], '')
117
+ arr = v.is_a?(Array) ? v : v.to_s.split('/')
118
+ arr.map { |p| safe_encode_segment(p.to_s) }.join('/')
119
+ end
120
+ end
121
+
122
+ out = '/' + parts.join('/')
123
+ out = '/' if out == ''
124
+
125
+ @gen_cache.set(cache_key, out)
126
+ out
127
+ end
128
+
47
129
  private
48
130
 
131
+ # compile helpers (memoize)
132
+ def compiled_segments
133
+ @compiled_segments ||= begin
134
+ if path == '/'
135
+ []
136
+ else
137
+ path.split('/').reject(&:empty?).map do |seg|
138
+ if seg.start_with?(':')
139
+ { type: :param, name: seg[1..-1] }
140
+ elsif seg.start_with?('*')
141
+ { type: :splat, name: (seg[1..-1] || 'splat') }
142
+ else
143
+ { type: :static, value: seg }
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ def compiled_required_params
151
+ @compiled_required_params ||= compiled_segments.select { |s| s[:type] != :static }
152
+ .map { |s| s[:name] }.uniq
153
+ .reject { |n| defaults.to_s.include?(n) }
154
+ end
155
+
156
+ # Cache key: deterministic param-order key (fast, stable)
157
+ def cache_key_for(merged)
158
+ # build key in route token order (parameters & splat) to avoid sorting/inspect
159
+ names = compiled_param_names
160
+ names.map { |n| merged[n].to_s }.join('|')
161
+ end
162
+
163
+ def compiled_param_names
164
+ @compiled_param_names ||= compiled_segments.map { |s| s[:name] if s[:type] != :static }.compact
165
+ end
166
+
167
+ # Only URI-encode a segment when it contains unsafe chars.
168
+ UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/
169
+ def safe_encode_segment(str)
170
+ # leave slash handling to splat logic (splats already split)
171
+ return str if UNRESERVED_RE.match?(str)
172
+ URI.encode_www_form_component(str)
173
+ end
174
+
49
175
  def normalize_path(path)
50
- path = "/#{path}" unless path.start_with?('/')
51
- # Remove trailing slash unless it's the root path
52
- path = path.chomp('/') unless path == '/'
53
- path
176
+ p = path.to_s
177
+ p = "/#{p}" unless p.start_with?('/')
178
+ p = p.chomp('/') unless p == '/'
179
+ p
54
180
  end
55
181
 
56
182
  def extract_controller(options)
@@ -67,26 +193,58 @@ module RubyRoutes
67
193
  end
68
194
 
69
195
  def extract_path_params(request_path)
70
- route_parts = path.split('/')
71
- request_parts = request_path.split('/')
196
+ segs = compiled_segments # memoized compiled route tokens
197
+ return nil if segs.empty? && request_path != '/'
72
198
 
73
- return nil if route_parts.length != request_parts.length
199
+ req = request_path
200
+ req = req[1..-1] if req.start_with?('/')
201
+ req = req[0...-1] if req.end_with?('/') && req != '/'
202
+ request_parts = req == '' ? [] : req.split('/')
203
+
204
+ return nil if segs.size != request_parts.size
74
205
 
75
206
  params = {}
76
- route_parts.each_with_index do |route_part, index|
77
- request_part = request_parts[index]
78
-
79
- if route_part.start_with?(':')
80
- param_name = route_part[1..-1]
81
- params[param_name] = request_part
82
- elsif route_part != request_part
83
- return nil
207
+ segs.each_with_index do |seg, idx|
208
+ case seg[:type]
209
+ when :static
210
+ return nil unless seg[:value] == request_parts[idx]
211
+ when :param
212
+ params[seg[:name]] = request_parts[idx]
213
+ when :splat
214
+ params[seg[:name]] = request_parts[idx..-1].join('/')
215
+ break
84
216
  end
85
217
  end
86
218
 
87
219
  params
88
220
  end
89
221
 
222
+ def query_params(path)
223
+ qidx = path.index('?')
224
+ return {} unless qidx
225
+ qs = path[(qidx + 1)..-1] || ''
226
+ Rack::Utils.parse_query(qs).transform_keys(&:to_s)
227
+ end
228
+
229
+ def validate_constraints!(params)
230
+ constraints.each do |param, constraint|
231
+ value = params[param.to_s]
232
+ next unless value
233
+
234
+ case constraint
235
+ when Regexp
236
+ raise ConstraintViolation unless constraint.match?(value)
237
+ when Proc
238
+ raise ConstraintViolation unless constraint.call(value)
239
+ when Symbol
240
+ case constraint
241
+ when :int then raise ConstraintViolation unless value.match?(/^\d+$/)
242
+ when :uuid then raise ConstraintViolation unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
243
+ end
244
+ end
245
+ end
246
+ end
247
+
90
248
  def validate_route!
91
249
  raise InvalidRoute, "Controller is required" if controller.nil?
92
250
  raise InvalidRoute, "Action is required" if action.nil?
@@ -3,18 +3,22 @@ module RubyRoutes
3
3
  attr_reader :routes
4
4
 
5
5
  def initialize
6
- @routes = []
6
+ @tree = RubyRoutes::RadixTree.new
7
7
  @named_routes = {}
8
+ @routes = [] # keep list for specs / iteration / size
8
9
  end
9
10
 
10
11
  def add_route(route)
11
12
  @routes << route
13
+ @tree.add(route.path, route.methods, route)
12
14
  @named_routes[route.name] = route if route.named?
13
15
  route
14
16
  end
15
17
 
16
18
  def find_route(request_method, request_path)
17
- @routes.find { |route| route.match?(request_method, request_path) }
19
+ # Return the Route object (or nil) to match spec expectations.
20
+ handler, _params = @tree.find(request_path, request_method.to_s.upcase)
21
+ handler
18
22
  end
19
23
 
20
24
  def find_named_route(name)
@@ -22,12 +26,23 @@ module RubyRoutes
22
26
  end
23
27
 
24
28
  def match(request_method, request_path)
25
- route = find_route(request_method, request_path)
26
- return nil unless route
29
+ # Use RadixTree lookup and the path params it returns (avoid reparsing the path)
30
+ handler, path_params = @tree.find(request_path, request_method.to_s.upcase)
31
+ return nil unless handler
32
+
33
+ route = handler
34
+
35
+ # path_params are already string-keyed in RadixTree; merge defaults + query params
36
+ params = (path_params || {}).dup
37
+ params = route.defaults.transform_keys(&:to_s).merge(params)
38
+ params.merge!(route.send(:query_params, request_path))
39
+
40
+ # Note: lightweight constraint checks are performed during RadixTree#find.
41
+ # Skip full constraint re-validation here to avoid double work.
27
42
 
28
43
  {
29
44
  route: route,
30
- params: route.extract_params(request_path),
45
+ params: params,
31
46
  controller: route.controller,
32
47
  action: route.action
33
48
  }
@@ -43,22 +58,14 @@ module RubyRoutes
43
58
  end
44
59
 
45
60
  def generate_path_from_route(route, params = {})
46
- path = route.path.dup
47
-
48
- params.each do |key, value|
49
- path.gsub!(":#{key}", value.to_s)
50
- end
51
-
52
- # Remove any remaining :param placeholders
53
- path.gsub!(/\/:[^\/]+/, '')
54
- path.gsub!(/\/$/, '') if path != '/'
55
-
56
- path
61
+ # Delegate to Route#generate_path which uses precompiled segments + cache
62
+ route.generate_path(params)
57
63
  end
58
64
 
59
65
  def clear!
60
66
  @routes.clear
61
67
  @named_routes.clear
68
+ @tree = RadixTree.new
62
69
  end
63
70
 
64
71
  def size
@@ -1,3 +1,3 @@
1
1
  module RubyRoutes
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/ruby_routes.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "ruby_routes/route"
4
4
  require_relative "ruby_routes/route_set"
5
5
  require_relative "ruby_routes/url_helpers"
6
6
  require_relative "ruby_routes/router"
7
+ require_relative "ruby_routes/radix_tree"
8
+ require_relative "ruby_routes/node"
7
9
 
8
10
  module RubyRoutes
9
11
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_routes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono
@@ -62,6 +62,8 @@ files:
62
62
  - LICENSE
63
63
  - README.md
64
64
  - lib/ruby_routes.rb
65
+ - lib/ruby_routes/node.rb
66
+ - lib/ruby_routes/radix_tree.rb
65
67
  - lib/ruby_routes/route.rb
66
68
  - lib/ruby_routes/route_set.rb
67
69
  - lib/ruby_routes/router.rb