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 +4 -4
- data/lib/ruby_routes/node.rb +24 -0
- data/lib/ruby_routes/radix_tree.rb +134 -0
- data/lib/ruby_routes/route.rb +180 -22
- data/lib/ruby_routes/route_set.rb +23 -16
- data/lib/ruby_routes/version.rb +1 -1
- data/lib/ruby_routes.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2bc2a63fd1cf72287ce09440410f609579ef5a3cdb4a33513187599dcb5b9ed7
|
4
|
+
data.tar.gz: 560ab2b883aeb3e42f74ef20fd675e3a692173724a78451bd5739115803944cf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/ruby_routes/route.rb
CHANGED
@@ -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
|
-
|
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
|
-
#
|
30
|
-
|
31
|
-
params.
|
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
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
71
|
-
|
196
|
+
segs = compiled_segments # memoized compiled route tokens
|
197
|
+
return nil if segs.empty? && request_path != '/'
|
72
198
|
|
73
|
-
|
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
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
params[
|
82
|
-
|
83
|
-
|
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
|
-
@
|
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
|
-
|
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
|
-
|
26
|
-
|
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:
|
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
|
-
|
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
|
data/lib/ruby_routes/version.rb
CHANGED
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.
|
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
|