ruby_routes 2.2.0 → 2.4.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +240 -163
  3. data/lib/ruby_routes/constant.rb +137 -18
  4. data/lib/ruby_routes/lru_strategies/hit_strategy.rb +31 -4
  5. data/lib/ruby_routes/lru_strategies/miss_strategy.rb +21 -0
  6. data/lib/ruby_routes/node.rb +86 -36
  7. data/lib/ruby_routes/radix_tree/finder.rb +213 -0
  8. data/lib/ruby_routes/radix_tree/inserter.rb +96 -0
  9. data/lib/ruby_routes/radix_tree.rb +65 -230
  10. data/lib/ruby_routes/route/check_helpers.rb +115 -0
  11. data/lib/ruby_routes/route/constraint_validator.rb +173 -0
  12. data/lib/ruby_routes/route/param_support.rb +200 -0
  13. data/lib/ruby_routes/route/path_builder.rb +84 -0
  14. data/lib/ruby_routes/route/path_generation.rb +87 -0
  15. data/lib/ruby_routes/route/query_helpers.rb +56 -0
  16. data/lib/ruby_routes/route/segment_compiler.rb +166 -0
  17. data/lib/ruby_routes/route/small_lru.rb +93 -18
  18. data/lib/ruby_routes/route/validation_helpers.rb +174 -0
  19. data/lib/ruby_routes/route/warning_helpers.rb +57 -0
  20. data/lib/ruby_routes/route.rb +127 -501
  21. data/lib/ruby_routes/route_set/cache_helpers.rb +76 -0
  22. data/lib/ruby_routes/route_set/collection_helpers.rb +125 -0
  23. data/lib/ruby_routes/route_set.rb +140 -132
  24. data/lib/ruby_routes/router/build_helpers.rb +99 -0
  25. data/lib/ruby_routes/router/builder.rb +97 -0
  26. data/lib/ruby_routes/router/http_helpers.rb +135 -0
  27. data/lib/ruby_routes/router/resource_helpers.rb +137 -0
  28. data/lib/ruby_routes/router/scope_helpers.rb +127 -0
  29. data/lib/ruby_routes/router.rb +196 -182
  30. data/lib/ruby_routes/segment.rb +28 -8
  31. data/lib/ruby_routes/segments/base_segment.rb +40 -4
  32. data/lib/ruby_routes/segments/dynamic_segment.rb +48 -12
  33. data/lib/ruby_routes/segments/static_segment.rb +43 -7
  34. data/lib/ruby_routes/segments/wildcard_segment.rb +58 -12
  35. data/lib/ruby_routes/string_extensions.rb +52 -15
  36. data/lib/ruby_routes/url_helpers.rb +106 -24
  37. data/lib/ruby_routes/utility/inflector_utility.rb +35 -0
  38. data/lib/ruby_routes/utility/key_builder_utility.rb +171 -77
  39. data/lib/ruby_routes/utility/method_utility.rb +137 -0
  40. data/lib/ruby_routes/utility/path_utility.rb +75 -28
  41. data/lib/ruby_routes/utility/route_utility.rb +30 -2
  42. data/lib/ruby_routes/version.rb +3 -1
  43. data/lib/ruby_routes.rb +68 -11
  44. metadata +27 -7
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRoutes
4
+ class RadixTree
5
+ # Inserter module for adding routes to the RadixTree.
6
+ # Handles tokenization, node advancement, and endpoint finalization.
7
+ module Inserter
8
+ private
9
+
10
+ # Inserts a route into the RadixTree for the given path and HTTP methods.
11
+ #
12
+ # @param path_string [String] the path to insert
13
+ # @param http_methods [Array<String>] the HTTP methods for the route
14
+ # @param route_handler [Object] the handler for the route
15
+ # @return [Object] the route handler
16
+ def insert_route(path_string, http_methods, route_handler)
17
+ return route_handler if path_string.nil? || path_string.empty?
18
+
19
+ tokens = split_path(path_string)
20
+ current_node = @root
21
+ tokens.each { |token| current_node = advance_node(current_node, token) }
22
+ finalize_endpoint(current_node, http_methods, route_handler)
23
+ route_handler
24
+ end
25
+
26
+ # Advances to the next node based on the token type.
27
+ #
28
+ # @param current_node [Node] the current node in the tree
29
+ # @param token [String] the token to process
30
+ # @return [Node] the next node
31
+ def advance_node(current_node, token)
32
+ case token[0]
33
+ when ':'
34
+ handle_dynamic(current_node, token)
35
+ when '*'
36
+ handle_wildcard(current_node, token)
37
+ else
38
+ handle_static(current_node, token)
39
+ end
40
+ end
41
+
42
+ # Handles dynamic parameter tokens (e.g., :id).
43
+ #
44
+ # @param current_node [Node] the current node
45
+ # @param token [String] the dynamic token
46
+ # @return [Node] the dynamic child node
47
+ def handle_dynamic(current_node, token)
48
+ param_name = token[1..]
49
+ current_node.dynamic_child ||= build_param_node(param_name)
50
+ current_node.dynamic_child
51
+ end
52
+
53
+ # Handles wildcard tokens (e.g., *splat).
54
+ #
55
+ # @param current_node [Node] the current node
56
+ # @param token [String] the wildcard token
57
+ # @return [Node] the wildcard child node
58
+ def handle_wildcard(current_node, token)
59
+ param_name = token[1..]
60
+ param_name = 'splat' if param_name.nil? || param_name.empty?
61
+ current_node.wildcard_child ||= build_param_node(param_name)
62
+ current_node.wildcard_child
63
+ end
64
+
65
+ # Handles static literal tokens.
66
+ #
67
+ # @param current_node [Node] the current node
68
+ # @param token [String] the static token
69
+ # @return [Node] the static child node
70
+ def handle_static(current_node, token)
71
+ literal_token = token.freeze
72
+ current_node.static_children[literal_token] ||= Node.new
73
+ current_node.static_children[literal_token]
74
+ end
75
+
76
+ # Builds a new node for parameter capture.
77
+ #
78
+ # @param param_name [String] the parameter name
79
+ # @return [Node] the new parameter node
80
+ def build_param_node(param_name)
81
+ node = Node.new
82
+ node.param_name = param_name
83
+ node
84
+ end
85
+
86
+ # Finalizes the endpoint by adding handlers for HTTP methods.
87
+ #
88
+ # @param node [Node] the endpoint node
89
+ # @param http_methods [Array<String>] the HTTP methods
90
+ # @param route_handler [Object] the route handler
91
+ def finalize_endpoint(node, http_methods, route_handler)
92
+ http_methods.each { |http_method| node.add_handler(http_method, route_handler) }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,15 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route/small_lru'
1
4
  require_relative 'segment'
2
5
  require_relative 'utility/path_utility'
6
+ require_relative 'utility/method_utility'
7
+ require_relative 'node'
8
+ require_relative 'radix_tree/inserter'
9
+ require_relative 'radix_tree/finder'
3
10
 
4
11
  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.
12
+ # RadixTree
13
+ #
14
+ # Compact routing trie supporting:
15
+ # - Static segments (/users)
16
+ # - Dynamic segments (/users/:id)
17
+ # - Wildcard splat (/assets/*path)
18
+ #
19
+ # Design / Behavior:
20
+ # - Traversal keeps track of the deepest valid endpoint encountered
21
+ # (best_match_node) so that if a later branch fails, we can still
22
+ # return a shorter matching route.
23
+ # - Dynamic and wildcard captures are written directly into the caller
24
+ # supplied params Hash (or a fresh one) to avoid intermediate objects.
25
+ # - A very small manual LRU (Hash + order Array) caches the result of
26
+ # splitting raw paths into their segment arrays.
27
+ #
28
+ # Matching Precedence:
29
+ # static > dynamic > wildcard
30
+ #
31
+ # Thread Safety:
32
+ # - Not thread‑safe for mutation (intended for boot‑time construction).
33
+ # Safe for concurrent reads after routes are added.
34
+ #
35
+ # @api internal
8
36
  class RadixTree
9
37
  include RubyRoutes::Utility::PathUtility
38
+ include RubyRoutes::Utility::MethodUtility
39
+ include Inserter
40
+ include Finder
10
41
 
11
42
  class << self
12
- # Allow RadixTree.new(path, options...) to act as a convenience factory
43
+ # Backwards DSL convenience: RadixTree.new(args) Route
13
44
  def new(*args, &block)
14
45
  if args.any?
15
46
  RubyRoutes::Route.new(*args, &block)
@@ -19,244 +50,48 @@ module RubyRoutes
19
50
  end
20
51
  end
21
52
 
53
+ # Initialize empty tree and split cache.
22
54
  def initialize
23
- @root = Node.new
24
- @split_cache = {}
25
- @split_cache_order = []
26
- @split_cache_max = 2048
27
- @empty_segments = [].freeze
55
+ @root = Node.new
56
+ @split_cache = RubyRoutes::Route::SmallLru.new(2048)
57
+ @split_cache_max = 2048
58
+ @split_cache_order = []
59
+ @empty_segment_list = [].freeze
28
60
  end
29
61
 
30
- # Add a route to the radix tree with specified path, HTTP methods, and handler.
31
- # Returns the handler for method chaining.
32
- def add(path, methods, handler)
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]
68
-
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
93
- end
94
-
95
- handler
62
+ # Add a route to the tree (delegates insertion logic).
63
+ #
64
+ # @param raw_path [String]
65
+ # @param http_methods [Array<String,Symbol>]
66
+ # @param route_handler [Object]
67
+ # @return [Object] route_handler
68
+ def add(raw_path, http_methods, route_handler)
69
+ normalized_path = normalize_path(raw_path)
70
+ normalized_methods = http_methods.map { |m| normalize_http_method(m) }
71
+ insert_route(normalized_path, normalized_methods, route_handler)
72
+ route_handler
96
73
  end
97
74
 
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 || {}]
111
- else
112
- return [nil, params_out || {}]
113
- end
114
- end
115
-
116
- # Split path into segments
117
- segments = split_path_cached(path_str)
118
- return [nil, params_out || {}] if segments.empty?
119
-
120
- params = params_out || {}
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]
144
- end
145
- return [nil, params]
146
- end
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
155
- end
156
-
157
- break if should_break # For wildcard paths
158
- end
159
-
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
181
-
182
- return [handler, params]
183
- end
184
-
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]
75
+ # Public find delegates to Finder#find (now simplified on this class).
76
+ def find(request_path_input, request_method_input, params_out = {})
77
+ super
198
78
  end
199
79
 
200
80
  private
201
81
 
202
- # Improved LRU Path Segment Cache: Accessed keys are moved to the end of the
203
- # order array to ensure proper LRU eviction behavior
204
- def split_path_cached(path)
205
- return @empty_segments if path == '/'
82
+ # Split path with small manual LRU cache.
83
+ #
84
+ # @param raw_path [String]
85
+ # @return [Array<String>]
86
+ def split_path_cached(raw_path)
87
+ return @empty_segment_list if raw_path == '/'
206
88
 
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]
213
- end
214
-
215
- # Split path and add to cache
216
- segments = split_path(path)
217
-
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)
222
- end
223
-
224
- @split_cache[path] = segments
225
- @split_cache_order << path
89
+ cached = @split_cache.get(raw_path)
90
+ return cached if cached
226
91
 
92
+ segments = split_path(raw_path)
93
+ @split_cache.set(raw_path, segments)
227
94
  segments
228
95
  end
229
-
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)
234
-
235
- constraints = handler.constraints
236
- return true unless constraints && !constraints.empty?
237
-
238
- # Check each constraint
239
- constraints.each do |param, constraint|
240
- param_key = param.to_s
241
- value = params[param_key]
242
- next unless value
243
-
244
- case constraint
245
- when Regexp
246
- return false unless constraint.match?(value.to_s)
247
- when :int
248
- return false unless value.to_s.match?(/\A\d+\z/)
249
- when :uuid
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
256
- end
257
- end
258
-
259
- true
260
- end
261
96
  end
262
97
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRoutes
4
+ class Route
5
+ # CheckHelpers: small helpers for validating hash-form constraints.
6
+ #
7
+ # This module provides methods for validating various hash-form constraints,
8
+ # such as minimum length, maximum length, format, inclusion, exclusion, and range.
9
+ # Each method raises a `RubyRoutes::ConstraintViolation` exception if the
10
+ # validation fails.
11
+ module CheckHelpers
12
+ # Check minimum length constraint.
13
+ #
14
+ # This method validates that the value meets the minimum length specified
15
+ # in the constraint. If the value is shorter than the minimum length, a
16
+ # `ConstraintViolation` is raised.
17
+ #
18
+ # @param constraint [Hash] The constraint hash containing `:min_length`.
19
+ # @param value [String] The value to validate.
20
+ # @raise [RubyRoutes::ConstraintViolation] If the value is shorter than the minimum length.
21
+ # @return [void]
22
+ def check_min_length(constraint, value)
23
+ return unless (min = constraint[:min_length]) && value && value.length < min
24
+
25
+ raise RubyRoutes::ConstraintViolation, "Value too short (minimum #{constraint[:min_length]} characters)"
26
+ end
27
+
28
+ # Check maximum length constraint.
29
+ #
30
+ # This method validates that the value does not exceed the maximum length
31
+ # specified in the constraint. If the value is longer than the maximum length,
32
+ # a `ConstraintViolation` is raised.
33
+ #
34
+ # @param constraint [Hash] The constraint hash containing `:max_length`.
35
+ # @param value [String] The value to validate.
36
+ # @raise [RubyRoutes::ConstraintViolation] If the value exceeds the maximum length.
37
+ # @return [void]
38
+ def check_max_length(constraint, value)
39
+ return unless (max = constraint[:max_length]) && value && value.length > max
40
+
41
+ raise RubyRoutes::ConstraintViolation, "Value too long (maximum #{constraint[:max_length]} characters)"
42
+ end
43
+
44
+ # Check format constraint.
45
+ #
46
+ # This method validates that the value matches the format specified in the
47
+ # constraint. If the value does not match the format, a `ConstraintViolation`
48
+ # is raised.
49
+ #
50
+ # @param constraint [Hash] The constraint hash containing `:format` (a Regexp).
51
+ # @param value [String] The value to validate.
52
+ # @raise [RubyRoutes::ConstraintViolation] If the value does not match the required format.
53
+ # @return [void]
54
+ def check_format(constraint, value)
55
+ return unless (format = constraint[:format]) && value && !value.match?(format)
56
+
57
+ raise RubyRoutes::ConstraintViolation, 'Value does not match required format'
58
+ end
59
+
60
+ # Check in list constraint.
61
+ #
62
+ # This method validates that the value is included in the list specified
63
+ # in the constraint. If the value is not in the list, a `ConstraintViolation`
64
+ # is raised.
65
+ #
66
+ # @param constraint [Hash] The constraint hash containing `:in` (an Array).
67
+ # @param value [String] The value to validate.
68
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not in the allowed list.
69
+ # @return [void]
70
+ def check_in_list(constraint, value)
71
+ return unless constraint[:in] && !constraint[:in].include?(value)
72
+
73
+ raise RubyRoutes::ConstraintViolation, 'Value not in allowed list'
74
+ end
75
+
76
+ # Check not in list constraint.
77
+ #
78
+ # This method validates that the value is not included in the list specified
79
+ # in the constraint. If the value is in the forbidden list, a `ConstraintViolation`
80
+ # is raised.
81
+ #
82
+ # @param constraint [Hash] The constraint hash containing `:not_in` (an Array).
83
+ # @param value [String] The value to validate.
84
+ # @raise [RubyRoutes::ConstraintViolation] If the value is in the forbidden list.
85
+ # @return [void]
86
+ def check_not_in_list(constraint, value)
87
+ return unless constraint[:not_in]&.include?(value)
88
+
89
+ raise RubyRoutes::ConstraintViolation, 'Value in forbidden list'
90
+ end
91
+
92
+ # Check range constraint.
93
+ #
94
+ # This method validates that the value falls within the range specified
95
+ # in the constraint. If the value is outside the range, a `ConstraintViolation`
96
+ # is raised.
97
+ #
98
+ # @param constraint [Hash] The constraint hash containing `:range` (a Range).
99
+ # @param value [String] The value to validate.
100
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not in the allowed range.
101
+ # @return [void]
102
+ def check_range(constraint, value)
103
+ range = constraint[:range]
104
+ return unless range
105
+ begin
106
+ integer_value = Integer(value) # raises on nil, floats, or junk like "10abc"
107
+ rescue ArgumentError, TypeError
108
+ raise RubyRoutes::ConstraintViolation, 'Value not in allowed range'
109
+ end
110
+
111
+ raise RubyRoutes::ConstraintViolation, 'Value not in allowed range' unless range.cover?(integer_value)
112
+ end
113
+ end
114
+ end
115
+ end