ruby_routes 2.1.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/node.rb +40 -41
- data/lib/ruby_routes/radix_tree.rb +194 -105
- data/lib/ruby_routes/route/small_lru.rb +4 -0
- data/lib/ruby_routes/route.rb +136 -89
- data/lib/ruby_routes/route_set.rb +95 -104
- data/lib/ruby_routes/router.rb +14 -9
- 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 +4 -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/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,156 +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
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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 || {}]
|
49
111
|
else
|
50
|
-
return [nil, {}]
|
112
|
+
return [nil, params_out || {}]
|
51
113
|
end
|
52
114
|
end
|
53
115
|
|
54
|
-
|
55
|
-
|
116
|
+
# Split path into segments
|
117
|
+
segments = split_path_cached(path_str)
|
118
|
+
return [nil, params_out || {}] if segments.empty?
|
119
|
+
|
56
120
|
params = params_out || {}
|
57
|
-
|
58
|
-
|
59
|
-
#
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
unless should_break
|
81
|
-
next_node, _ = current.traverse_for(segments[2], 2, segments, params)
|
82
|
-
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]
|
83
144
|
end
|
145
|
+
return [nil, params]
|
84
146
|
end
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
current
|
91
|
-
|
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
|
92
155
|
end
|
156
|
+
|
157
|
+
break if should_break # For wildcard paths
|
93
158
|
end
|
94
159
|
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
98
181
|
|
99
|
-
|
100
|
-
if handler.respond_to?(:constraints) && !handler.constraints.empty?
|
101
|
-
return [nil, {}] unless constraints_match_fast(handler.constraints, params)
|
182
|
+
return [handler, params]
|
102
183
|
end
|
103
184
|
|
104
|
-
|
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]
|
105
198
|
end
|
106
199
|
|
107
200
|
private
|
108
201
|
|
109
|
-
#
|
202
|
+
# Improved LRU Path Segment Cache: Accessed keys are moved to the end of the
|
203
|
+
# order array to ensure proper LRU eviction behavior
|
110
204
|
def split_path_cached(path)
|
111
|
-
return @empty_segments if path == '/'
|
205
|
+
return @empty_segments if path == '/'
|
112
206
|
|
113
|
-
if
|
114
|
-
|
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]
|
115
213
|
end
|
116
214
|
|
117
|
-
|
215
|
+
# Split path and add to cache
|
216
|
+
segments = split_path(path)
|
118
217
|
|
119
|
-
#
|
120
|
-
@split_cache
|
121
|
-
|
122
|
-
|
123
|
-
oldest = @split_cache_order.shift
|
124
|
-
@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)
|
125
222
|
end
|
126
223
|
|
127
|
-
|
224
|
+
@split_cache[path] = segments
|
225
|
+
@split_cache_order << path
|
226
|
+
|
227
|
+
segments
|
128
228
|
end
|
129
229
|
|
130
|
-
#
|
131
|
-
|
132
|
-
|
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)
|
133
234
|
|
134
|
-
|
135
|
-
|
136
|
-
end_idx = path.end_with?('/') ? -2 : -1
|
235
|
+
constraints = handler.constraints
|
236
|
+
return true unless constraints && !constraints.empty?
|
137
237
|
|
138
|
-
|
139
|
-
path.split('/')
|
140
|
-
else
|
141
|
-
path[start_idx..end_idx].split('/')
|
142
|
-
end
|
143
|
-
end
|
144
|
-
|
145
|
-
# Optimized constraint matching with fast paths
|
146
|
-
def constraints_match_fast(constraints, params)
|
238
|
+
# Check each constraint
|
147
239
|
constraints.each do |param, constraint|
|
148
|
-
|
149
|
-
value = params[
|
150
|
-
value ||= params[param] if param.respond_to?(:to_s)
|
240
|
+
param_key = param.to_s
|
241
|
+
value = params[param_key]
|
151
242
|
next unless value
|
152
243
|
|
153
244
|
case constraint
|
154
245
|
when Regexp
|
155
|
-
return false unless constraint.match?(value)
|
156
|
-
when Proc
|
157
|
-
return false unless constraint.call(value)
|
246
|
+
return false unless constraint.match?(value.to_s)
|
158
247
|
when :int
|
159
|
-
|
160
|
-
return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
|
248
|
+
return false unless value.to_s.match?(/\A\d+\z/)
|
161
249
|
when :uuid
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
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
|
168
256
|
end
|
169
257
|
end
|
258
|
+
|
170
259
|
true
|
171
260
|
end
|
172
261
|
end
|
data/lib/ruby_routes/route.rb
CHANGED
@@ -3,23 +3,32 @@ require 'timeout'
|
|
3
3
|
require 'set'
|
4
4
|
require 'rack'
|
5
5
|
require_relative 'route/small_lru'
|
6
|
+
require_relative 'utility/path_utility'
|
7
|
+
require_relative 'utility/key_builder_utility'
|
6
8
|
|
7
9
|
module RubyRoutes
|
8
10
|
class Route
|
11
|
+
include RubyRoutes::Utility::PathUtility
|
12
|
+
include RubyRoutes::Utility::KeyBuilderUtility
|
13
|
+
|
9
14
|
attr_reader :path, :methods, :controller, :action, :name, :constraints, :defaults
|
10
15
|
|
16
|
+
EMPTY_ARRAY = [].freeze
|
17
|
+
EMPTY_PAIR = [EMPTY_ARRAY, EMPTY_ARRAY].freeze
|
18
|
+
EMPTY_STRING = ''.freeze
|
19
|
+
|
11
20
|
def initialize(path, options = {})
|
12
21
|
@path = normalize_path(path)
|
13
22
|
# Pre-normalize and freeze methods at creation time
|
14
|
-
raw_methods
|
15
|
-
@methods
|
16
|
-
@methods_set
|
17
|
-
@controller
|
18
|
-
@action
|
19
|
-
@name
|
20
|
-
@constraints
|
23
|
+
raw_methods = Array(options[:via] || :get)
|
24
|
+
@methods = raw_methods.map { |m| normalize_method(m) }.freeze
|
25
|
+
@methods_set = @methods.to_set.freeze
|
26
|
+
@controller = extract_controller(options)
|
27
|
+
@action = options[:action] || extract_action(options[:to])
|
28
|
+
@name = options[:as]
|
29
|
+
@constraints = options[:constraints] || {}
|
21
30
|
# Pre-normalize defaults to string keys and freeze
|
22
|
-
@defaults
|
31
|
+
@defaults = (options[:defaults] || {}).transform_keys(&:to_s).freeze
|
23
32
|
|
24
33
|
# Pre-compile everything at initialization
|
25
34
|
precompile_route_data
|
@@ -59,35 +68,17 @@ module RubyRoutes
|
|
59
68
|
|
60
69
|
# Optimized path generation with better caching and fewer allocations
|
61
70
|
def generate_path(params = {})
|
62
|
-
return
|
71
|
+
return @static_path if @static_path && (params.nil? || params.empty?)
|
72
|
+
params ||= {}
|
73
|
+
missing, nils = validate_required_params(params)
|
74
|
+
raise RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
|
75
|
+
raise RouteNotFound, "Missing or nil params: #{nils.join(', ')}" unless nils.empty?
|
63
76
|
|
64
|
-
# Fast path: empty params and no required params
|
65
|
-
if params.empty? && @required_params.empty?
|
66
|
-
return @static_path if @static_path
|
67
|
-
end
|
68
|
-
|
69
|
-
# Build merged params efficiently
|
70
77
|
merged = build_merged_params(params)
|
71
|
-
|
72
|
-
# Check required params (fast Set operation)
|
73
|
-
missing_params = @required_params_set - merged.keys
|
74
|
-
unless missing_params.empty?
|
75
|
-
raise RubyRoutes::RouteNotFound, "Missing params: #{missing_params.to_a.join(', ')}"
|
76
|
-
end
|
77
|
-
|
78
|
-
# Check for nil values in required params
|
79
|
-
nil_params = @required_params_set.select { |param| merged[param].nil? }
|
80
|
-
unless nil_params.empty?
|
81
|
-
raise RubyRoutes::RouteNotFound, "Missing or nil params: #{nil_params.to_a.join(', ')}"
|
82
|
-
end
|
83
|
-
|
84
|
-
# Cache lookup
|
85
|
-
cache_key = build_cache_key_fast(merged)
|
78
|
+
cache_key = cache_key_for_params(@required_params, merged)
|
86
79
|
if (cached = @gen_cache.get(cache_key))
|
87
80
|
return cached
|
88
81
|
end
|
89
|
-
|
90
|
-
# Generate path using string buffer (avoid array allocations)
|
91
82
|
path_str = generate_path_string(merged)
|
92
83
|
@gen_cache.set(cache_key, path_str)
|
93
84
|
path_str
|
@@ -134,6 +125,7 @@ module RubyRoutes
|
|
134
125
|
@is_resource = @path.match?(/\/:id(?:$|\.)/)
|
135
126
|
@gen_cache = SmallLru.new(512) # larger cache
|
136
127
|
@query_cache = SmallLru.new(QUERY_CACHE_SIZE)
|
128
|
+
initialize_validation_cache
|
137
129
|
|
138
130
|
compile_segments
|
139
131
|
compile_required_params
|
@@ -193,7 +185,7 @@ module RubyRoutes
|
|
193
185
|
# Validate constraints efficiently
|
194
186
|
validate_constraints_fast!(result) unless @constraints.empty?
|
195
187
|
|
196
|
-
result
|
188
|
+
result
|
197
189
|
end
|
198
190
|
|
199
191
|
def get_thread_local_hash
|
@@ -222,7 +214,7 @@ module RubyRoutes
|
|
222
214
|
return EMPTY_HASH if @compiled_segments.empty? && request_path == ROOT_PATH
|
223
215
|
return nil if @compiled_segments.empty?
|
224
216
|
|
225
|
-
path_parts =
|
217
|
+
path_parts = split_path(request_path)
|
226
218
|
|
227
219
|
# Check for wildcard/splat segment
|
228
220
|
has_splat = @compiled_segments.any? { |seg| seg[:type] == :splat }
|
@@ -236,14 +228,6 @@ module RubyRoutes
|
|
236
228
|
extract_params_from_parts(path_parts)
|
237
229
|
end
|
238
230
|
|
239
|
-
def split_path_fast(request_path)
|
240
|
-
# Remove query string before splitting
|
241
|
-
path = request_path.split('?', 2).first
|
242
|
-
path = path[1..-1] if path.start_with?('/')
|
243
|
-
path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
|
244
|
-
path.empty? ? [] : path.split('/')
|
245
|
-
end
|
246
|
-
|
247
231
|
def extract_params_from_parts(path_parts)
|
248
232
|
params = {}
|
249
233
|
|
@@ -264,22 +248,16 @@ module RubyRoutes
|
|
264
248
|
|
265
249
|
# Optimized merged params building
|
266
250
|
def build_merged_params(params)
|
267
|
-
return @defaults if params.empty?
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
if params.respond_to?(:transform_keys)
|
276
|
-
merged.merge!(params.transform_keys(&:to_s))
|
277
|
-
else
|
278
|
-
# Fallback for older Ruby versions
|
279
|
-
params.each { |k, v| merged[k.to_s] = v }
|
251
|
+
return @defaults if params.nil? || params.empty?
|
252
|
+
h = Thread.current[:ruby_routes_merge_hash] ||= {}
|
253
|
+
h.clear
|
254
|
+
@defaults.each { |k,v| h[k] = v }
|
255
|
+
params.each do |k,v|
|
256
|
+
next if v.nil?
|
257
|
+
ks = k.is_a?(String) ? k : k.to_s
|
258
|
+
h[ks] = v
|
280
259
|
end
|
281
|
-
|
282
|
-
merged
|
260
|
+
h
|
283
261
|
end
|
284
262
|
|
285
263
|
def get_thread_local_merged_hash
|
@@ -288,41 +266,43 @@ module RubyRoutes
|
|
288
266
|
hash
|
289
267
|
end
|
290
268
|
|
291
|
-
# Fast cache key building with minimal allocations
|
292
|
-
def build_cache_key_fast(merged)
|
293
|
-
return '' if @required_params.empty?
|
294
|
-
|
295
|
-
# Use array join which is faster than string concatenation
|
296
|
-
parts = @required_params.map do |name|
|
297
|
-
value = merged[name]
|
298
|
-
value.is_a?(Array) ? value.join('/') : value.to_s
|
299
|
-
end
|
300
|
-
parts.join('|')
|
301
|
-
end
|
302
|
-
|
303
269
|
# Optimized path generation
|
304
270
|
def generate_path_string(merged)
|
305
271
|
return ROOT_PATH if @compiled_segments.empty?
|
306
272
|
|
307
|
-
#
|
308
|
-
|
309
|
-
|
273
|
+
# Estimate final path length to avoid resizing
|
274
|
+
estimated_size = 1 # For leading slash
|
310
275
|
@compiled_segments.each do |seg|
|
311
276
|
case seg[:type]
|
312
277
|
when :static
|
313
|
-
|
278
|
+
estimated_size += seg[:value].length + 1 # +1 for slash
|
279
|
+
when :param, :splat
|
280
|
+
estimated_size += 20 # Average param length estimate
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
# Use string buffer with pre-allocated capacity
|
285
|
+
path = String.new(capacity: estimated_size)
|
286
|
+
path << '/'
|
287
|
+
|
288
|
+
# Generate path directly into buffer
|
289
|
+
last_idx = @compiled_segments.size - 1
|
290
|
+
@compiled_segments.each_with_index do |seg, i|
|
291
|
+
case seg[:type]
|
292
|
+
when :static
|
293
|
+
path << seg[:value]
|
314
294
|
when :param
|
315
295
|
value = merged.fetch(seg[:name]).to_s
|
316
|
-
|
296
|
+
path << encode_segment_fast(value)
|
317
297
|
when :splat
|
318
298
|
value = merged.fetch(seg[:name], '')
|
319
|
-
|
299
|
+
path << format_splat_value(value)
|
320
300
|
end
|
301
|
+
|
302
|
+
path << '/' unless i == last_idx
|
321
303
|
end
|
322
304
|
|
323
|
-
|
324
|
-
path = "/#{parts.join('/')}"
|
325
|
-
path == '/' ? ROOT_PATH : path
|
305
|
+
path
|
326
306
|
end
|
327
307
|
|
328
308
|
def format_splat_value(value)
|
@@ -366,13 +346,6 @@ module RubyRoutes
|
|
366
346
|
result
|
367
347
|
end
|
368
348
|
|
369
|
-
def normalize_path(path)
|
370
|
-
path_str = path.to_s
|
371
|
-
path_str = "/#{path_str}" unless path_str.start_with?('/')
|
372
|
-
path_str = path_str.chomp('/') unless path_str == ROOT_PATH
|
373
|
-
path_str
|
374
|
-
end
|
375
|
-
|
376
349
|
def extract_controller(options)
|
377
350
|
to = options[:to]
|
378
351
|
return options[:controller] unless to
|
@@ -384,6 +357,84 @@ module RubyRoutes
|
|
384
357
|
to.to_s.split('#', 2).last
|
385
358
|
end
|
386
359
|
|
360
|
+
|
361
|
+
def validate_required_params(params)
|
362
|
+
return EMPTY_PAIR if @required_params.empty?
|
363
|
+
missing = nil
|
364
|
+
nils = nil
|
365
|
+
@required_params.each do |rk|
|
366
|
+
if params.key?(rk)
|
367
|
+
(nils ||= []) << rk if params[rk].nil?
|
368
|
+
elsif params.key?(sym = rk.to_sym)
|
369
|
+
(nils ||= []) << rk if params[sym].nil?
|
370
|
+
else
|
371
|
+
(missing ||= []) << rk
|
372
|
+
end
|
373
|
+
end
|
374
|
+
[missing || EMPTY_ARRAY, nils || EMPTY_ARRAY]
|
375
|
+
end
|
376
|
+
|
377
|
+
# Add this validation cache management
|
378
|
+
def initialize_validation_cache
|
379
|
+
@validation_cache = SmallLru.new(64)
|
380
|
+
end
|
381
|
+
|
382
|
+
def cache_validation_result(params, result)
|
383
|
+
# Only cache immutable params to prevent subtle bugs
|
384
|
+
if params.frozen? && @validation_cache && @validation_cache.size < 64
|
385
|
+
@validation_cache.set(params.hash, result)
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
def get_cached_validation(params)
|
390
|
+
return nil unless @validation_cache
|
391
|
+
@validation_cache.get(params.hash)
|
392
|
+
end
|
393
|
+
|
394
|
+
# Fast parameter type detection with result caching
|
395
|
+
def params_type(params)
|
396
|
+
# Cache parameter type detection results
|
397
|
+
@params_type_cache ||= {}
|
398
|
+
param_obj_id = params.object_id
|
399
|
+
|
400
|
+
# Return cached type if available
|
401
|
+
return @params_type_cache[param_obj_id] if @params_type_cache.key?(param_obj_id)
|
402
|
+
|
403
|
+
# Type detection with explicit checks
|
404
|
+
type = if params.is_a?(Hash)
|
405
|
+
# Further refine hash type for optimization
|
406
|
+
refine_hash_type(params)
|
407
|
+
elsif params.respond_to?(:each) && params.respond_to?(:[])
|
408
|
+
# Hash-like enumerable
|
409
|
+
:enumerable
|
410
|
+
else
|
411
|
+
# Last resort - may
|
412
|
+
:mehod_missing
|
413
|
+
end
|
414
|
+
|
415
|
+
# Keep cache small
|
416
|
+
if @params_type_cache.size > 100
|
417
|
+
@params_type_cache.clear
|
418
|
+
end
|
419
|
+
|
420
|
+
# Cache result
|
421
|
+
@params_type_cache[param_obj_id] = type
|
422
|
+
end
|
423
|
+
|
424
|
+
# Further refine hash type for potential optimization
|
425
|
+
def refine_hash_type(params)
|
426
|
+
# Only sample a few keys to determine type tendency
|
427
|
+
key_samples = params.keys.take(3)
|
428
|
+
|
429
|
+
if key_samples.all? { |k| k.is_a?(String) }
|
430
|
+
:string_keyed_hash
|
431
|
+
elsif key_samples.all? { |k| k.is_a?(Symbol) }
|
432
|
+
:symbol_keyed_hash
|
433
|
+
else
|
434
|
+
:hash # Mixed keys
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
387
438
|
# Optimized constraint validation
|
388
439
|
def validate_constraints_fast!(params)
|
389
440
|
@constraints.each do |param, constraint|
|
@@ -497,9 +548,5 @@ module RubyRoutes
|
|
497
548
|
raise InvalidRoute, "Action is required" if @action.nil?
|
498
549
|
raise InvalidRoute, "Invalid HTTP method: #{@methods}" if @methods.empty?
|
499
550
|
end
|
500
|
-
|
501
|
-
# Additional constants
|
502
|
-
EMPTY_ARRAY = [].freeze
|
503
|
-
EMPTY_STRING = ''.freeze
|
504
551
|
end
|
505
552
|
end
|
@@ -1,94 +1,103 @@
|
|
1
|
+
require_relative 'utility/key_builder_utility'
|
2
|
+
|
1
3
|
module RubyRoutes
|
2
4
|
class RouteSet
|
3
5
|
attr_reader :routes
|
4
6
|
|
7
|
+
include RubyRoutes::Utility::KeyBuilderUtility
|
8
|
+
|
5
9
|
def initialize
|
6
|
-
@tree = RubyRoutes::RadixTree.new
|
7
|
-
@named_routes = {}
|
8
10
|
@routes = []
|
9
|
-
|
11
|
+
@named_routes = {}
|
10
12
|
@recognition_cache = {}
|
13
|
+
@recognition_cache_max = 2048
|
11
14
|
@cache_hits = 0
|
12
15
|
@cache_misses = 0
|
13
|
-
@
|
16
|
+
@radix_tree = RadixTree.new
|
14
17
|
end
|
15
18
|
|
16
|
-
def
|
19
|
+
def add_to_collection(route)
|
17
20
|
@routes << route
|
18
|
-
@
|
21
|
+
@radix_tree.add(route.path, route.methods, route)
|
19
22
|
@named_routes[route.name] = route if route.named?
|
20
|
-
# Clear recognition cache when routes change
|
21
|
-
@recognition_cache.clear if @recognition_cache.size > 100
|
22
|
-
route
|
23
23
|
end
|
24
24
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
25
|
+
alias_method :add_route, :add_to_collection
|
26
|
+
|
27
|
+
def find_route(method, path)
|
28
|
+
route, _ = @radix_tree.find(path, method)
|
29
|
+
route
|
30
30
|
end
|
31
31
|
|
32
32
|
def find_named_route(name)
|
33
33
|
route = @named_routes[name]
|
34
|
-
|
35
|
-
|
34
|
+
raise RouteNotFound.new("No route named '#{name}'") unless route
|
35
|
+
route
|
36
36
|
end
|
37
37
|
|
38
|
-
|
39
|
-
|
40
|
-
|
38
|
+
FAST_METHOD_MAP = {
|
39
|
+
get: 'GET', post: 'POST', put: 'PUT', patch: 'PATCH',
|
40
|
+
delete: 'DELETE', head: 'HEAD', options: 'OPTIONS'
|
41
|
+
}.freeze
|
42
|
+
|
43
|
+
def normalize_method_input(method)
|
44
|
+
case method
|
45
|
+
when Symbol
|
46
|
+
FAST_METHOD_MAP[method] || method.to_s.upcase
|
47
|
+
when String
|
48
|
+
# Fast path: assume already correct; fallback only for common lowercase
|
49
|
+
return method if method.length <= 6 && method == method.upcase
|
50
|
+
FAST_METHOD_MAP[method.downcase.to_sym] || method.upcase
|
51
|
+
else
|
52
|
+
s = method.to_s
|
53
|
+
FAST_METHOD_MAP[s.downcase.to_sym] || s.upcase
|
54
|
+
end
|
55
|
+
end
|
56
|
+
private :normalize_method_input
|
41
57
|
|
42
|
-
|
43
|
-
|
58
|
+
def match(method, path)
|
59
|
+
m = normalize_method_input(method)
|
60
|
+
raw = path.to_s
|
61
|
+
cache_key = cache_key_for_request(m, raw)
|
44
62
|
|
45
|
-
#
|
46
|
-
if (
|
63
|
+
# Single cache lookup with proper hit accounting
|
64
|
+
if (hit = @recognition_cache[cache_key])
|
47
65
|
@cache_hits += 1
|
48
|
-
return
|
66
|
+
return hit
|
49
67
|
end
|
50
68
|
|
51
69
|
@cache_misses += 1
|
52
70
|
|
53
|
-
|
54
|
-
params = get_thread_local_params
|
55
|
-
handler, _ = @tree.find(request_path, method_up, params)
|
56
|
-
return nil unless handler
|
71
|
+
path_without_query, _qs = raw.split('?', 2)
|
57
72
|
|
58
|
-
|
73
|
+
# Use normalized method (m) for trie lookup
|
74
|
+
route, params = @radix_tree.find(path_without_query, m)
|
75
|
+
return nil unless route
|
59
76
|
|
60
|
-
|
61
|
-
merge_defaults(route, params) if route.defaults && !route.defaults.empty?
|
77
|
+
merge_query_params(route, raw, params)
|
62
78
|
|
63
|
-
|
64
|
-
|
65
|
-
merge_query_params(route, request_path, params)
|
79
|
+
if route.respond_to?(:defaults) && route.defaults
|
80
|
+
route.defaults.each { |k,v| params[k.to_s] = v unless params.key?(k.to_s) }
|
66
81
|
end
|
67
82
|
|
68
|
-
# Create return hash and cache the complete result
|
69
|
-
result_params = params.dup
|
70
83
|
result = {
|
71
84
|
route: route,
|
72
|
-
params:
|
85
|
+
params: params,
|
73
86
|
controller: route.controller,
|
74
87
|
action: route.action
|
75
|
-
}
|
88
|
+
}
|
76
89
|
|
77
90
|
insert_cache_entry(cache_key, result)
|
78
91
|
result
|
79
92
|
end
|
80
93
|
|
81
|
-
def recognize_path(path, method =
|
94
|
+
def recognize_path(path, method = 'GET')
|
82
95
|
match(method, path)
|
83
96
|
end
|
84
97
|
|
85
98
|
def generate_path(name, params = {})
|
86
|
-
route =
|
87
|
-
|
88
|
-
route.generate_path(params)
|
89
|
-
else
|
90
|
-
raise RouteNotFound, "No route named '#{name}'"
|
91
|
-
end
|
99
|
+
route = find_named_route(name)
|
100
|
+
route.generate_path(params)
|
92
101
|
end
|
93
102
|
|
94
103
|
def generate_path_from_route(route, params = {})
|
@@ -99,94 +108,76 @@ module RubyRoutes
|
|
99
108
|
@routes.clear
|
100
109
|
@named_routes.clear
|
101
110
|
@recognition_cache.clear
|
102
|
-
@
|
103
|
-
@
|
111
|
+
@cache_hits = 0
|
112
|
+
@cache_misses = 0
|
113
|
+
# Create a new radix tree since we can't clear it
|
114
|
+
@radix_tree = RadixTree.new
|
104
115
|
end
|
105
116
|
|
106
117
|
def size
|
107
118
|
@routes.size
|
108
119
|
end
|
109
|
-
alias_method :length, :size
|
110
120
|
|
111
121
|
def empty?
|
112
122
|
@routes.empty?
|
113
123
|
end
|
114
124
|
|
115
|
-
def each(&block)
|
116
|
-
return enum_for(:each) unless block_given?
|
117
|
-
@routes.each(&block)
|
118
|
-
end
|
119
|
-
|
120
|
-
def include?(route)
|
121
|
-
@routes.include?(route)
|
122
|
-
end
|
123
|
-
|
124
|
-
# Performance monitoring
|
125
125
|
def cache_stats
|
126
|
-
|
127
|
-
hit_rate = total > 0 ? (@cache_hits.to_f / total * 100).round(2) : 0
|
126
|
+
lookups = @cache_hits + @cache_misses
|
128
127
|
{
|
129
128
|
hits: @cache_hits,
|
130
129
|
misses: @cache_misses,
|
131
|
-
hit_rate:
|
130
|
+
hit_rate: lookups.zero? ? 0.0 : (@cache_hits.to_f / lookups * 100.0),
|
132
131
|
size: @recognition_cache.size
|
133
132
|
}
|
134
133
|
end
|
135
134
|
|
136
|
-
|
137
|
-
|
138
|
-
# Method lookup table to avoid repeated upcasing with interned strings
|
139
|
-
def method_lookup(method)
|
140
|
-
@method_cache ||= Hash.new { |h, k| h[k] = k.to_s.upcase.freeze }
|
141
|
-
@method_cache[method]
|
142
|
-
end
|
143
|
-
|
144
|
-
# Optimized cache key building - avoid string interpolation
|
145
|
-
def build_cache_key(method, path)
|
146
|
-
# String interpolation creates a new string directly without intermediate allocations
|
147
|
-
"#{method}:#{path}"
|
135
|
+
def each(&block)
|
136
|
+
@routes.each(&block)
|
148
137
|
end
|
149
138
|
|
150
|
-
|
151
|
-
|
152
|
-
# Use single thread-local hash that gets cleared, avoiding pool management overhead
|
153
|
-
hash = Thread.current[:ruby_routes_params_hash] ||= {}
|
154
|
-
hash.clear
|
155
|
-
hash
|
139
|
+
def include?(route)
|
140
|
+
@routes.include?(route)
|
156
141
|
end
|
157
142
|
|
158
|
-
|
159
|
-
# No-op since we're using a single reusable hash per thread
|
160
|
-
end
|
143
|
+
private
|
161
144
|
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
145
|
+
def insert_cache_entry(key, value)
|
146
|
+
# unchanged cache insert (key already frozen & reusable)
|
147
|
+
if @recognition_cache.size >= @recognition_cache_max
|
148
|
+
@recognition_cache.keys.first(@recognition_cache_max / 4).each { |k| @recognition_cache.delete(k) }
|
149
|
+
end
|
150
|
+
@recognition_cache[key] = value
|
151
|
+
end
|
152
|
+
|
153
|
+
# Add the missing method for merging query params
|
154
|
+
def merge_query_params(route, path, params)
|
155
|
+
# Check for query string
|
156
|
+
if path.to_s.include?('?')
|
157
|
+
if route.respond_to?(:parse_query_params)
|
158
|
+
query_params = route.parse_query_params(path)
|
159
|
+
params.merge!(query_params) if query_params
|
160
|
+
elsif route.respond_to?(:query_params)
|
161
|
+
query_params = route.query_params(path)
|
162
|
+
params.merge!(query_params) if query_params
|
163
|
+
end
|
166
164
|
end
|
167
165
|
end
|
168
166
|
|
169
|
-
#
|
170
|
-
def
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
params.merge!(qp) unless qp.empty?
|
167
|
+
# Add thread-local params pool methods
|
168
|
+
def get_thread_local_params
|
169
|
+
thread_params = Thread.current[:ruby_routes_params_pool] ||= []
|
170
|
+
if thread_params.empty?
|
171
|
+
{}
|
172
|
+
else
|
173
|
+
thread_params.pop.clear
|
177
174
|
end
|
178
175
|
end
|
179
176
|
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
# Simple eviction: clear cache when it gets too large
|
185
|
-
if @recognition_cache.size > @recognition_cache_max
|
186
|
-
# Keep most recently used half
|
187
|
-
keys_to_delete = @recognition_cache.keys[0...(@recognition_cache_max / 2)]
|
188
|
-
keys_to_delete.each { |k| @recognition_cache.delete(k) }
|
189
|
-
end
|
177
|
+
def return_params_to_pool(params)
|
178
|
+
params.clear
|
179
|
+
thread_pool = Thread.current[:ruby_routes_params_pool] ||= []
|
180
|
+
thread_pool << params if thread_pool.size < 10 # Limit pool size
|
190
181
|
end
|
191
182
|
end
|
192
183
|
end
|
data/lib/ruby_routes/router.rb
CHANGED
@@ -1,9 +1,12 @@
|
|
1
|
+
require_relative 'utility/route_utility'
|
2
|
+
|
1
3
|
module RubyRoutes
|
2
4
|
class Router
|
3
5
|
attr_reader :route_set
|
4
6
|
|
5
7
|
def initialize(&block)
|
6
8
|
@route_set = RouteSet.new
|
9
|
+
@route_utils = RubyRoutes::Utility::RouteUtility.new(@route_set)
|
7
10
|
@scope_stack = []
|
8
11
|
@concerns = {}
|
9
12
|
instance_eval(&block) if block_given?
|
@@ -102,7 +105,14 @@ module RubyRoutes
|
|
102
105
|
end
|
103
106
|
|
104
107
|
# Scope support
|
105
|
-
def scope(
|
108
|
+
def scope(options_or_path = {}, &block)
|
109
|
+
# Handle the case where the first argument is a string (path)
|
110
|
+
options = if options_or_path.is_a?(String)
|
111
|
+
{ path: options_or_path }
|
112
|
+
else
|
113
|
+
options_or_path
|
114
|
+
end
|
115
|
+
|
106
116
|
@scope_stack.push(options)
|
107
117
|
|
108
118
|
if block_given?
|
@@ -165,14 +175,9 @@ module RubyRoutes
|
|
165
175
|
|
166
176
|
private
|
167
177
|
|
168
|
-
def add_route(path, options
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
# Create and add the route
|
173
|
-
route = Route.new(scoped_options[:path], scoped_options)
|
174
|
-
@route_set.add_route(route)
|
175
|
-
route
|
178
|
+
def add_route(path, options={})
|
179
|
+
scoped = apply_scope(path, options)
|
180
|
+
@route_utils.define(scoped[:path], scoped)
|
176
181
|
end
|
177
182
|
|
178
183
|
def apply_scope(path, options)
|
@@ -0,0 +1,102 @@
|
|
1
|
+
module RubyRoutes
|
2
|
+
module Utility
|
3
|
+
module KeyBuilderUtility
|
4
|
+
# ------------------------------------------------------------------
|
5
|
+
# Fast reusable key storage for "METHOD:PATH" strings.
|
6
|
+
# O(1) insert + O(1) eviction using a fixed-size ring buffer.
|
7
|
+
# Only allocates a new String on the *first* time a (method,path) pair appears.
|
8
|
+
# ------------------------------------------------------------------
|
9
|
+
REQUEST_KEY_CAPACITY = 4096
|
10
|
+
|
11
|
+
@pool = {} # { method_string => { path_string => frozen_key_string } }
|
12
|
+
@ring = Array.new(REQUEST_KEY_CAPACITY) # ring entries: [method_string, path_string]
|
13
|
+
@ring_pos = 0
|
14
|
+
@entry_cnt = 0
|
15
|
+
|
16
|
+
class << self
|
17
|
+
attr_reader :pool
|
18
|
+
def fetch_request_key(method, path)
|
19
|
+
# Method & path must be strings already (callers ensure)
|
20
|
+
if (paths = @pool[method])
|
21
|
+
if (key = paths[path])
|
22
|
+
return key
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# MISS: build & freeze once
|
27
|
+
key = "#{method}:#{path}".freeze
|
28
|
+
if paths
|
29
|
+
paths[path] = key
|
30
|
+
else
|
31
|
+
@pool[method] = { path => key }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Evict if ring full (overwrite oldest slot)
|
35
|
+
if @entry_cnt < REQUEST_KEY_CAPACITY
|
36
|
+
@ring[@entry_cnt] = [method, path]
|
37
|
+
@entry_cnt += 1
|
38
|
+
else
|
39
|
+
ev_m, ev_p = @ring[@ring_pos]
|
40
|
+
bucket = @pool[ev_m]
|
41
|
+
if bucket&.delete(ev_p) && bucket.empty?
|
42
|
+
@pool.delete(ev_m)
|
43
|
+
end
|
44
|
+
@ring[@ring_pos] = [method, path]
|
45
|
+
@ring_pos += 1
|
46
|
+
@ring_pos = 0 if @ring_pos == REQUEST_KEY_CAPACITY
|
47
|
+
end
|
48
|
+
|
49
|
+
key
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# ------------------------------------------------------------------
|
54
|
+
# Public helpers mixed into instances
|
55
|
+
# ------------------------------------------------------------------
|
56
|
+
|
57
|
+
# Generic key (rarely hot): joins parts with delim; single allocation.
|
58
|
+
def build_key(parts, delim = ':')
|
59
|
+
return ''.freeze if parts.empty?
|
60
|
+
buf = Thread.current[:ruby_routes_key_buf] ||= String.new
|
61
|
+
buf.clear
|
62
|
+
i = 0
|
63
|
+
while i < parts.length
|
64
|
+
buf << delim unless i.zero?
|
65
|
+
buf << parts[i].to_s
|
66
|
+
i += 1
|
67
|
+
end
|
68
|
+
buf.dup
|
69
|
+
end
|
70
|
+
|
71
|
+
# HOT: request cache key (reused frozen interned string)
|
72
|
+
def cache_key_for_request(method, path)
|
73
|
+
KeyBuilderUtility.fetch_request_key(method, path.to_s)
|
74
|
+
end
|
75
|
+
|
76
|
+
# HOT: params key – produces a short-lived String (dup, not re-frozen each time).
|
77
|
+
# Callers usually put it into an LRU that duplicates again, so keep it lean.
|
78
|
+
def cache_key_for_params(required_params, merged)
|
79
|
+
return ''.freeze if required_params.nil? || required_params.empty?
|
80
|
+
buf = Thread.current[:ruby_routes_param_key_buf] ||= String.new
|
81
|
+
buf.clear
|
82
|
+
i = 0
|
83
|
+
while i < required_params.length
|
84
|
+
buf << '|' unless i.zero?
|
85
|
+
v = merged[required_params[i]]
|
86
|
+
if v.is_a?(Array)
|
87
|
+
j = 0
|
88
|
+
while j < v.length
|
89
|
+
buf << '/' unless j.zero?
|
90
|
+
buf << v[j].to_s
|
91
|
+
j += 1
|
92
|
+
end
|
93
|
+
else
|
94
|
+
buf << v.to_s
|
95
|
+
end
|
96
|
+
i += 1
|
97
|
+
end
|
98
|
+
buf.dup
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module RubyRoutes
|
2
|
+
module Utility
|
3
|
+
module PathUtility
|
4
|
+
ROOT_PATH = '/'.freeze
|
5
|
+
|
6
|
+
def normalize_path(path)
|
7
|
+
path = path.to_s
|
8
|
+
# Add leading slash if missing
|
9
|
+
path = '/' + path unless path.start_with?('/')
|
10
|
+
# Remove trailing slash if present (unless root)
|
11
|
+
path = path[0..-2] if path.length > 1 && path.end_with?('/')
|
12
|
+
path
|
13
|
+
end
|
14
|
+
|
15
|
+
def split_path(path)
|
16
|
+
# Remove query string before splitting
|
17
|
+
path = path.split('?', 2).first
|
18
|
+
path = path[1..-1] if path.start_with?('/')
|
19
|
+
path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
|
20
|
+
path.empty? ? [] : path.split('/')
|
21
|
+
end
|
22
|
+
|
23
|
+
def join_path_parts(parts)
|
24
|
+
# Pre-calculate the size to avoid buffer resizing
|
25
|
+
size = parts.sum { |p| p.length + 1 } # +1 for slash
|
26
|
+
|
27
|
+
# Use string buffer for better performance
|
28
|
+
result = String.new(capacity: size)
|
29
|
+
result << '/'
|
30
|
+
|
31
|
+
# Join with explicit concatenation rather than array join
|
32
|
+
last_idx = parts.size - 1
|
33
|
+
parts.each_with_index do |part, i|
|
34
|
+
result << part
|
35
|
+
result << '/' unless i == last_idx
|
36
|
+
end
|
37
|
+
|
38
|
+
result
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module RubyRoutes
|
2
|
+
module Utility
|
3
|
+
class RouteUtility
|
4
|
+
def initialize(route_set)
|
5
|
+
@route_set = route_set
|
6
|
+
end
|
7
|
+
|
8
|
+
# DSL wants to merge scope, RouteSet wants to add a pre‐built Route,
|
9
|
+
# so we offer two entry points:
|
10
|
+
def define(path, options = {})
|
11
|
+
route = Route.new(path, options)
|
12
|
+
register(route)
|
13
|
+
end
|
14
|
+
|
15
|
+
def register(route)
|
16
|
+
@route_set.add_to_collection(route)
|
17
|
+
route
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/ruby_routes/version.rb
CHANGED
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: 2.
|
4
|
+
version: 2.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yosef Benny Widyokarsono
|
@@ -92,6 +92,9 @@ files:
|
|
92
92
|
- lib/ruby_routes/segments/wildcard_segment.rb
|
93
93
|
- lib/ruby_routes/string_extensions.rb
|
94
94
|
- lib/ruby_routes/url_helpers.rb
|
95
|
+
- lib/ruby_routes/utility/key_builder_utility.rb
|
96
|
+
- lib/ruby_routes/utility/path_utility.rb
|
97
|
+
- lib/ruby_routes/utility/route_utility.rb
|
95
98
|
- lib/ruby_routes/version.rb
|
96
99
|
homepage: https://github.com/yosefbennywidyo/ruby_routes
|
97
100
|
licenses:
|