ruby_routes 2.0.0 → 2.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/constant.rb +1 -0
- data/lib/ruby_routes/node.rb +40 -41
- data/lib/ruby_routes/radix_tree.rb +194 -100
- data/lib/ruby_routes/route/small_lru.rb +4 -0
- data/lib/ruby_routes/route.rb +160 -111
- data/lib/ruby_routes/route_set.rb +96 -106
- data/lib/ruby_routes/router.rb +14 -9
- data/lib/ruby_routes/string_extensions.rb +3 -1
- data/lib/ruby_routes/url_helpers.rb +3 -2
- data/lib/ruby_routes/utility/key_builder_utility.rb +102 -0
- data/lib/ruby_routes/utility/path_utility.rb +42 -0
- data/lib/ruby_routes/utility/route_utility.rb +21 -0
- data/lib/ruby_routes/version.rb +1 -1
- metadata +18 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 66764348265233b0904630e4193e21fc93f79e3257ec4d0e43e5c533faf1f7e1
|
4
|
+
data.tar.gz: de32cc9f5f42398b12e829c4da34aa82afd071fa63ed869180a56203834beecd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 21442f1a73e4045fedbb9bd215c639770c55135f6d55c78c17384996515d1347851d7b3afa952db345793a5aa92d5c14368d28faf0e35d4c985d608d94c8b980
|
7
|
+
data.tar.gz: 5570f4054ceaf76569bb4f28eca4d94347a559a24febe1936e39dfb9896ccfa281cdd1e2ce24e8e2b157b3d7f3870a67cc9ecdb61b62338bce7e28b5d0c32e4e
|
data/lib/ruby_routes/constant.rb
CHANGED
data/lib/ruby_routes/node.rb
CHANGED
@@ -1,65 +1,64 @@
|
|
1
1
|
require_relative 'segment'
|
2
2
|
|
3
3
|
module RubyRoutes
|
4
|
+
# Node represents a single node in the radix tree structure.
|
5
|
+
# Each node can have static children (exact matches), one dynamic child (parameter capture),
|
6
|
+
# and one wildcard child (consumes remaining path segments).
|
4
7
|
class Node
|
5
|
-
attr_accessor :
|
6
|
-
|
8
|
+
attr_accessor :param_name, :is_endpoint, :dynamic_child, :wildcard_child
|
9
|
+
attr_reader :handlers, :static_children
|
7
10
|
|
8
11
|
def initialize
|
12
|
+
@is_endpoint = false
|
13
|
+
@handlers = {}
|
9
14
|
@static_children = {}
|
10
15
|
@dynamic_child = nil
|
11
16
|
@wildcard_child = nil
|
12
|
-
@handlers = {}
|
13
17
|
@param_name = nil
|
14
|
-
@is_endpoint = false
|
15
18
|
end
|
16
19
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
return [child, false] if child
|
20
|
+
def add_handler(method, handler)
|
21
|
+
method_str = normalize_method(method)
|
22
|
+
@handlers[method_str] = handler
|
23
|
+
@is_endpoint = true
|
24
|
+
end
|
23
25
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
return [dyn, false]
|
28
|
-
end
|
26
|
+
def get_handler(method)
|
27
|
+
@handlers[method]
|
28
|
+
end
|
29
29
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
30
|
+
# Fast traversal method with minimal allocations and streamlined branching.
|
31
|
+
# Matching order: static (most specific) → dynamic → wildcard (least specific)
|
32
|
+
# Returns [next_node_or_nil, should_break_bool] where should_break indicates
|
33
|
+
# wildcard capture that consumes remaining path segments.
|
34
|
+
def traverse_for(segment, index, segments, params)
|
35
|
+
# Try static child first (most specific) - O(1) hash lookup
|
36
|
+
if @static_children.key?(segment)
|
37
|
+
return [@static_children[segment], false]
|
38
|
+
# Try dynamic child (parameter capture) - less specific than static
|
39
|
+
elsif @dynamic_child
|
40
|
+
# Capture parameter if params hash provided and param_name is set
|
41
|
+
params[@dynamic_child.param_name] = segment if params && @dynamic_child.param_name
|
42
|
+
return [@dynamic_child, false]
|
43
|
+
# Try wildcard child (consumes remaining segments) - least specific
|
44
|
+
elsif @wildcard_child
|
45
|
+
# Capture remaining path segments for wildcard parameter
|
46
|
+
if params && @wildcard_child.param_name
|
47
|
+
remaining = segments[index..-1]
|
48
|
+
params[@wildcard_child.param_name] = remaining.join('/')
|
36
49
|
end
|
37
|
-
return [
|
50
|
+
return [@wildcard_child, true] # true signals to stop traversal
|
38
51
|
end
|
39
52
|
|
40
|
-
# No match
|
53
|
+
# No match found at this node
|
41
54
|
[nil, false]
|
42
55
|
end
|
43
56
|
|
44
|
-
|
45
|
-
def param_name
|
46
|
-
@param_name_str ||= @param_name&.to_s
|
47
|
-
end
|
48
|
-
|
49
|
-
def param_name=(name)
|
50
|
-
@param_name = name
|
51
|
-
@param_name_str = nil # invalidate cache
|
52
|
-
end
|
53
|
-
|
54
|
-
# Normalize method once and cache string keys
|
55
|
-
def add_handler(method, handler)
|
56
|
-
method_key = method.to_s.upcase
|
57
|
-
@handlers[method_key] = handler
|
58
|
-
@is_endpoint = true
|
59
|
-
end
|
57
|
+
private
|
60
58
|
|
61
|
-
|
62
|
-
|
59
|
+
# Fast method normalization - converts method to uppercase string
|
60
|
+
def normalize_method(method)
|
61
|
+
method.to_s.upcase
|
63
62
|
end
|
64
63
|
end
|
65
64
|
end
|
@@ -1,7 +1,13 @@
|
|
1
1
|
require_relative 'segment'
|
2
|
+
require_relative 'utility/path_utility'
|
2
3
|
|
3
4
|
module RubyRoutes
|
5
|
+
# RadixTree provides an optimized tree structure for fast route matching.
|
6
|
+
# Supports static segments, dynamic parameters (:param), and wildcards (*splat).
|
7
|
+
# Features longest prefix matching and improved LRU caching for performance.
|
4
8
|
class RadixTree
|
9
|
+
include RubyRoutes::Utility::PathUtility
|
10
|
+
|
5
11
|
class << self
|
6
12
|
# Allow RadixTree.new(path, options...) to act as a convenience factory
|
7
13
|
def new(*args, &block)
|
@@ -17,151 +23,239 @@ module RubyRoutes
|
|
17
23
|
@root = Node.new
|
18
24
|
@split_cache = {}
|
19
25
|
@split_cache_order = []
|
20
|
-
@split_cache_max = 2048
|
21
|
-
@empty_segments = [].freeze
|
26
|
+
@split_cache_max = 2048
|
27
|
+
@empty_segments = [].freeze
|
22
28
|
end
|
23
29
|
|
30
|
+
# Add a route to the radix tree with specified path, HTTP methods, and handler.
|
31
|
+
# Returns the handler for method chaining.
|
24
32
|
def add(path, methods, handler)
|
25
|
-
|
26
|
-
|
33
|
+
# Normalize path
|
34
|
+
normalized_path = normalize_path(path)
|
35
|
+
|
36
|
+
# Insert into the tree
|
37
|
+
insert_route(normalized_path, methods, handler)
|
38
|
+
|
39
|
+
# Return handler for chaining
|
40
|
+
handler
|
41
|
+
end
|
42
|
+
|
43
|
+
# Insert a route into the tree structure, creating nodes as needed.
|
44
|
+
# Supports static segments, dynamic parameters (:param), and wildcards (*splat).
|
45
|
+
def insert_route(path_str, methods, handler)
|
46
|
+
# Skip empty paths
|
47
|
+
return handler if path_str.nil? || path_str.empty?
|
48
|
+
|
49
|
+
path_parts = split_path(path_str)
|
50
|
+
current_node = @root
|
51
|
+
|
52
|
+
# Add path segments to tree
|
53
|
+
path_parts.each_with_index do |segment, i|
|
54
|
+
if segment.start_with?(':')
|
55
|
+
# Dynamic segment (e.g., :id)
|
56
|
+
param_name = segment[1..-1]
|
57
|
+
|
58
|
+
# Create dynamic child if needed
|
59
|
+
unless current_node.dynamic_child
|
60
|
+
current_node.dynamic_child = Node.new
|
61
|
+
current_node.dynamic_child.param_name = param_name
|
62
|
+
end
|
63
|
+
|
64
|
+
current_node = current_node.dynamic_child
|
65
|
+
elsif segment.start_with?('*')
|
66
|
+
# Wildcard segment (e.g., *path)
|
67
|
+
param_name = segment[1..-1]
|
27
68
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
69
|
+
# Create wildcard child if needed
|
70
|
+
unless current_node.wildcard_child
|
71
|
+
current_node.wildcard_child = Node.new
|
72
|
+
current_node.wildcard_child.param_name = param_name
|
73
|
+
end
|
74
|
+
|
75
|
+
current_node = current_node.wildcard_child
|
76
|
+
break # Wildcard consumes the rest of the path
|
77
|
+
else
|
78
|
+
# Static segment - freeze key for memory efficiency and performance
|
79
|
+
segment_key = segment.freeze
|
80
|
+
unless current_node.static_children[segment_key]
|
81
|
+
current_node.static_children[segment_key] = Node.new
|
82
|
+
end
|
83
|
+
|
84
|
+
current_node = current_node.static_children[segment_key]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Mark node as endpoint and add handler for methods
|
89
|
+
current_node.is_endpoint = true
|
90
|
+
Array(methods).each do |method|
|
91
|
+
method_str = method.to_s.upcase
|
92
|
+
current_node.handlers[method_str] = handler
|
32
93
|
end
|
33
94
|
|
34
|
-
|
35
|
-
Array(methods).each { |method| current.add_handler(method.to_s.upcase, handler) }
|
95
|
+
handler
|
36
96
|
end
|
37
97
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
98
|
+
# Find a matching route in the radix tree with longest prefix match support.
|
99
|
+
# Tracks the deepest endpoint node during traversal so partial matches return
|
100
|
+
# the longest valid prefix, increasing matching flexibility and correctness
|
101
|
+
# for overlapping/static/dynamic/wildcard routes.
|
102
|
+
def find(path, method, params_out = {})
|
103
|
+
# Handle empty path as root
|
104
|
+
path_str = path.to_s
|
105
|
+
method_str = method.to_s.upcase
|
106
|
+
|
107
|
+
# Special case for root path
|
108
|
+
if path_str.empty? || path_str == '/'
|
109
|
+
if @root.is_endpoint && @root.handlers[method_str]
|
110
|
+
return [@root.handlers[method_str], params_out || {}]
|
44
111
|
else
|
45
|
-
return [nil, {}]
|
112
|
+
return [nil, params_out || {}]
|
46
113
|
end
|
47
114
|
end
|
48
115
|
|
49
|
-
|
50
|
-
|
116
|
+
# Split path into segments
|
117
|
+
segments = split_path_cached(path_str)
|
118
|
+
return [nil, params_out || {}] if segments.empty?
|
119
|
+
|
51
120
|
params = params_out || {}
|
52
|
-
|
53
|
-
|
54
|
-
#
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
unless should_break
|
76
|
-
next_node, _ = current.traverse_for(segments[2], 2, segments, params)
|
77
|
-
current = next_node
|
121
|
+
|
122
|
+
# Track the longest prefix match (deepest endpoint found during traversal)
|
123
|
+
# Only consider nodes that actually match some part of the path
|
124
|
+
longest_match_node = nil
|
125
|
+
longest_match_params = nil
|
126
|
+
|
127
|
+
# Traverse the tree to find matching route
|
128
|
+
current_node = @root
|
129
|
+
segments.each_with_index do |segment, i|
|
130
|
+
next_node, should_break = current_node.traverse_for(segment, i, segments, params)
|
131
|
+
|
132
|
+
# No match found for this segment
|
133
|
+
unless next_node
|
134
|
+
# Return longest prefix match if we found any valid endpoint during traversal
|
135
|
+
if longest_match_node
|
136
|
+
handler = longest_match_node.handlers[method_str]
|
137
|
+
if handler.respond_to?(:constraints)
|
138
|
+
constraints = handler.constraints
|
139
|
+
if constraints && !constraints.empty?
|
140
|
+
return check_constraints(handler, longest_match_params) ? [handler, longest_match_params] : [nil, params]
|
141
|
+
end
|
142
|
+
end
|
143
|
+
return [handler, longest_match_params]
|
78
144
|
end
|
145
|
+
return [nil, params]
|
79
146
|
end
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
current
|
86
|
-
|
147
|
+
|
148
|
+
current_node = next_node
|
149
|
+
|
150
|
+
# Check if current node is a valid endpoint after successful traversal
|
151
|
+
if current_node.is_endpoint && current_node.handlers[method_str]
|
152
|
+
# Store this as our current best match
|
153
|
+
longest_match_node = current_node
|
154
|
+
longest_match_params = params.dup
|
87
155
|
end
|
156
|
+
|
157
|
+
break if should_break # For wildcard paths
|
88
158
|
end
|
89
159
|
|
90
|
-
|
91
|
-
|
92
|
-
|
160
|
+
# Check if final node is an endpoint and has a handler for the method
|
161
|
+
if current_node.is_endpoint && current_node.handlers[method_str]
|
162
|
+
handler = current_node.handlers[method_str]
|
163
|
+
|
164
|
+
# Handle constraints correctly - only check constraints
|
165
|
+
# Don't try to call matches? which test doubles won't have properly stubbed
|
166
|
+
if handler.respond_to?(:constraints)
|
167
|
+
constraints = handler.constraints
|
168
|
+
if constraints && !constraints.empty?
|
169
|
+
if check_constraints(handler, params)
|
170
|
+
return [handler, params]
|
171
|
+
else
|
172
|
+
# If constraints fail, try longest prefix match as fallback
|
173
|
+
if longest_match_node && longest_match_node != current_node
|
174
|
+
fallback_handler = longest_match_node.handlers[method_str]
|
175
|
+
return [fallback_handler, longest_match_params] if fallback_handler
|
176
|
+
end
|
177
|
+
return [nil, params]
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
93
181
|
|
94
|
-
|
95
|
-
if handler.respond_to?(:constraints) && !handler.constraints.empty?
|
96
|
-
return [nil, {}] unless constraints_match_fast(handler.constraints, params)
|
182
|
+
return [handler, params]
|
97
183
|
end
|
98
184
|
|
99
|
-
|
185
|
+
# If we reach here, final node isn't an endpoint - return longest prefix match
|
186
|
+
if longest_match_node
|
187
|
+
handler = longest_match_node.handlers[method_str]
|
188
|
+
if handler.respond_to?(:constraints)
|
189
|
+
constraints = handler.constraints
|
190
|
+
if constraints && !constraints.empty?
|
191
|
+
return check_constraints(handler, longest_match_params) ? [handler, longest_match_params] : [nil, params]
|
192
|
+
end
|
193
|
+
end
|
194
|
+
return [handler, longest_match_params]
|
195
|
+
end
|
196
|
+
|
197
|
+
[nil, params]
|
100
198
|
end
|
101
199
|
|
102
200
|
private
|
103
201
|
|
104
|
-
#
|
202
|
+
# Improved LRU Path Segment Cache: Accessed keys are moved to the end of the
|
203
|
+
# order array to ensure proper LRU eviction behavior
|
105
204
|
def split_path_cached(path)
|
106
|
-
return @empty_segments if path == '/'
|
205
|
+
return @empty_segments if path == '/'
|
107
206
|
|
108
|
-
if
|
109
|
-
|
207
|
+
# Check if path is in cache
|
208
|
+
if @split_cache.key?(path)
|
209
|
+
# Move accessed key to end for proper LRU behavior
|
210
|
+
@split_cache_order.delete(path)
|
211
|
+
@split_cache_order << path
|
212
|
+
return @split_cache[path]
|
110
213
|
end
|
111
214
|
|
112
|
-
|
215
|
+
# Split path and add to cache
|
216
|
+
segments = split_path(path)
|
113
217
|
|
114
|
-
#
|
115
|
-
@split_cache
|
116
|
-
|
117
|
-
|
118
|
-
oldest = @split_cache_order.shift
|
119
|
-
@split_cache.delete(oldest)
|
218
|
+
# Manage cache size - evict oldest entries when limit reached
|
219
|
+
if @split_cache.size >= @split_cache_max
|
220
|
+
old_key = @split_cache_order.shift
|
221
|
+
@split_cache.delete(old_key)
|
120
222
|
end
|
121
223
|
|
122
|
-
|
224
|
+
@split_cache[path] = segments
|
225
|
+
@split_cache_order << path
|
226
|
+
|
227
|
+
segments
|
123
228
|
end
|
124
229
|
|
125
|
-
#
|
126
|
-
|
127
|
-
|
230
|
+
# Validates route constraints against extracted parameters.
|
231
|
+
# Returns true if all constraints pass, false otherwise.
|
232
|
+
def check_constraints(handler, params)
|
233
|
+
return true unless handler.respond_to?(:constraints)
|
128
234
|
|
129
|
-
|
130
|
-
|
131
|
-
end_idx = path.end_with?('/') ? -2 : -1
|
235
|
+
constraints = handler.constraints
|
236
|
+
return true unless constraints && !constraints.empty?
|
132
237
|
|
133
|
-
|
134
|
-
path.split('/')
|
135
|
-
else
|
136
|
-
path[start_idx..end_idx].split('/')
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
|
-
# Optimized constraint matching with fast paths
|
141
|
-
def constraints_match_fast(constraints, params)
|
238
|
+
# Check each constraint
|
142
239
|
constraints.each do |param, constraint|
|
143
|
-
|
144
|
-
value = params[
|
145
|
-
value ||= params[param] if param.respond_to?(:to_s)
|
240
|
+
param_key = param.to_s
|
241
|
+
value = params[param_key]
|
146
242
|
next unless value
|
147
243
|
|
148
244
|
case constraint
|
149
245
|
when Regexp
|
150
|
-
return false unless constraint.match?(value)
|
151
|
-
when Proc
|
152
|
-
return false unless constraint.call(value)
|
246
|
+
return false unless constraint.match?(value.to_s)
|
153
247
|
when :int
|
154
|
-
|
155
|
-
return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
|
248
|
+
return false unless value.to_s.match?(/\A\d+\z/)
|
156
249
|
when :uuid
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
250
|
+
return false unless value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
251
|
+
when Hash
|
252
|
+
if constraint[:range].is_a?(Range)
|
253
|
+
value_num = value.to_i
|
254
|
+
return false unless constraint[:range].include?(value_num)
|
255
|
+
end
|
163
256
|
end
|
164
257
|
end
|
258
|
+
|
165
259
|
true
|
166
260
|
end
|
167
261
|
end
|