ruby_routes 2.1.0 → 2.3.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 +232 -162
  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 +82 -41
  7. data/lib/ruby_routes/radix_tree/finder.rb +164 -0
  8. data/lib/ruby_routes/radix_tree/inserter.rb +98 -0
  9. data/lib/ruby_routes/radix_tree.rb +83 -142
  10. data/lib/ruby_routes/route/check_helpers.rb +109 -0
  11. data/lib/ruby_routes/route/constraint_validator.rb +159 -0
  12. data/lib/ruby_routes/route/param_support.rb +202 -0
  13. data/lib/ruby_routes/route/path_builder.rb +86 -0
  14. data/lib/ruby_routes/route/path_generation.rb +102 -0
  15. data/lib/ruby_routes/route/query_helpers.rb +56 -0
  16. data/lib/ruby_routes/route/segment_compiler.rb +163 -0
  17. data/lib/ruby_routes/route/small_lru.rb +96 -17
  18. data/lib/ruby_routes/route/validation_helpers.rb +151 -0
  19. data/lib/ruby_routes/route/warning_helpers.rb +54 -0
  20. data/lib/ruby_routes/route.rb +121 -451
  21. data/lib/ruby_routes/route_set/cache_helpers.rb +174 -0
  22. data/lib/ruby_routes/route_set/collection_helpers.rb +127 -0
  23. data/lib/ruby_routes/route_set.rb +126 -148
  24. data/lib/ruby_routes/router/build_helpers.rb +100 -0
  25. data/lib/ruby_routes/router/builder.rb +96 -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 +109 -0
  29. data/lib/ruby_routes/router.rb +196 -179
  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 +56 -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 +179 -0
  39. data/lib/ruby_routes/utility/method_utility.rb +137 -0
  40. data/lib/ruby_routes/utility/path_utility.rb +89 -0
  41. data/lib/ruby_routes/utility/route_utility.rb +49 -0
  42. data/lib/ruby_routes/version.rb +3 -1
  43. data/lib/ruby_routes.rb +68 -11
  44. metadata +30 -7
@@ -1,9 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route/small_lru'
1
4
  require_relative 'segment'
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'
2
10
 
3
11
  module RubyRoutes
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
4
36
  class RadixTree
37
+ include RubyRoutes::Utility::PathUtility
38
+ include RubyRoutes::Utility::MethodUtility
39
+ include Inserter
40
+ include Finder
41
+
5
42
  class << self
6
- # Allow RadixTree.new(path, options...) to act as a convenience factory
43
+ # Backwards DSL convenience: RadixTree.new(args) Route
7
44
  def new(*args, &block)
8
45
  if args.any?
9
46
  RubyRoutes::Route.new(*args, &block)
@@ -13,161 +50,65 @@ module RubyRoutes
13
50
  end
14
51
  end
15
52
 
53
+ # Initialize empty tree and split cache.
16
54
  def initialize
17
- @root = Node.new
18
- @split_cache = {}
19
- @split_cache_order = []
20
- @split_cache_max = 2048 # larger cache for better hit rates
21
- @empty_segments = [].freeze # reuse for root path
55
+ @root_node = Node.new
56
+ @split_cache = RubyRoutes::Route::SmallLru.new(2048)
57
+ @split_cache_max = 2048
58
+ @split_cache_order = []
59
+ @empty_segment_list = [].freeze
22
60
  end
23
61
 
24
- def add(path, methods, handler)
25
- current = @root
26
- segments = split_path_raw(path)
27
-
28
- segments.each do |raw_seg|
29
- seg = RubyRoutes::Segment.for(raw_seg)
30
- current = seg.ensure_child(current)
31
- break if seg.wildcard?
32
- end
33
-
34
- # Normalize methods once during registration
35
- Array(methods).each { |method| current.add_handler(method.to_s.upcase, 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
36
73
  end
37
74
 
38
- def find(path, method, params_out = nil)
39
- # Handle nil path and method cases
40
- path ||= ''
41
- method = method.to_s.upcase if method
42
- # Strip query string before matching
43
- clean_path = path.split('?', 2).first || ''
44
- # Fast path: root route
45
- if clean_path == '/' || clean_path.empty?
46
- handler = @root.get_handler(method)
47
- if @root.is_endpoint && handler
48
- return [handler, params_out || {}]
49
- else
50
- return [nil, {}]
51
- end
52
- end
53
-
54
- segments = split_path_cached(clean_path)
55
- current = @root
56
- params = params_out || {}
57
- params.clear if params_out
58
-
59
- # Unrolled traversal for common case (1-3 segments)
60
- case segments.size
61
- when 1
62
- next_node, _ = current.traverse_for(segments[0], 0, segments, params)
63
- current = next_node
64
- when 2
65
- next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
66
- return [nil, {}] unless next_node
67
- current = next_node
68
- unless should_break
69
- next_node, _ = current.traverse_for(segments[1], 1, segments, params)
70
- current = next_node
71
- end
72
- when 3
73
- next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
74
- return [nil, {}] unless next_node
75
- current = next_node
76
- unless should_break
77
- next_node, should_break = current.traverse_for(segments[1], 1, segments, params)
78
- return [nil, {}] unless next_node
79
- current = next_node
80
- unless should_break
81
- next_node, _ = current.traverse_for(segments[2], 2, segments, params)
82
- current = next_node
83
- end
84
- end
85
- else
86
- # General case for longer paths
87
- segments.each_with_index do |text, idx|
88
- next_node, should_break = current.traverse_for(text, idx, segments, params)
89
- return [nil, {}] unless next_node
90
- current = next_node
91
- break if should_break
92
- end
93
- end
94
-
95
- return [nil, {}] unless current
96
- handler = current.get_handler(method)
97
- return [nil, {}] unless current.is_endpoint && handler
98
-
99
- # Fast constraint check
100
- if handler.respond_to?(:constraints) && !handler.constraints.empty?
101
- return [nil, {}] unless constraints_match_fast(handler.constraints, params)
102
- end
103
-
104
- [handler, 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
105
78
  end
106
79
 
107
80
  private
108
81
 
109
- # Cached path splitting with optimized common cases
110
- def split_path_cached(path)
111
- return @empty_segments if path == '/' || path.empty?
112
-
113
- if (cached = @split_cache[path])
114
- return cached
115
- end
116
-
117
- result = split_path_raw(path)
118
-
119
- # Cache with simple LRU eviction
120
- @split_cache[path] = result
121
- @split_cache_order << path
122
- if @split_cache_order.size > @split_cache_max
123
- oldest = @split_cache_order.shift
124
- @split_cache.delete(oldest)
125
- end
126
-
127
- result
128
- end
129
-
130
- # Raw path splitting without caching (for registration)
131
- def split_path_raw(path)
132
- return [] if path == '/' || path.empty?
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 == '/'
133
88
 
134
- # Optimized trimming: avoid string allocations when possible
135
- start_idx = path.start_with?('/') ? 1 : 0
136
- end_idx = path.end_with?('/') ? -2 : -1
89
+ cached = @split_cache.get(raw_path)
90
+ return cached if cached
137
91
 
138
- if start_idx == 0 && end_idx == -1
139
- path.split('/')
140
- else
141
- path[start_idx..end_idx].split('/')
142
- end
92
+ segments = split_path(raw_path)
93
+ @split_cache.set(raw_path, segments)
94
+ segments
143
95
  end
144
96
 
145
- # Optimized constraint matching with fast paths
146
- def constraints_match_fast(constraints, params)
147
- constraints.each do |param, constraint|
148
- # Try both string and symbol keys (common pattern)
149
- value = params[param.to_s]
150
- value ||= params[param] if param.respond_to?(:to_s)
151
- next unless value
152
-
153
- case constraint
154
- when Regexp
155
- return false unless constraint.match?(value)
156
- when Proc
157
- return false unless constraint.call(value)
158
- when :int
159
- # Fast integer check without regex
160
- return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
161
- when :uuid
162
- # Fast UUID check
163
- return false unless value.is_a?(String) && value.length == 36 &&
164
- value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
165
- when Symbol
166
- # Handle other symbolic constraints
167
- next # unknown symbol constraint — allow
168
- end
97
+ # Evaluate constraint rules for a candidate route.
98
+ #
99
+ # @param route_handler [Object]
100
+ # @param captured_params [Hash]
101
+ # @return [Boolean]
102
+ def check_constraints(route_handler, captured_params)
103
+ return true unless route_handler.respond_to?(:validate_constraints_fast!)
104
+
105
+ begin
106
+ # Use a duplicate to avoid unintended mutation by validators.
107
+ route_handler.validate_constraints_fast!(captured_params)
108
+ true
109
+ rescue RubyRoutes::ConstraintViolation
110
+ false
169
111
  end
170
- true
171
112
  end
172
113
  end
173
114
  end
@@ -0,0 +1,109 @@
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 constraint[:min_length] && value.length < constraint[:min_length]
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 constraint[:max_length] && value.length > constraint[:max_length]
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 constraint[:format] && !value.match?(constraint[: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
+ return unless constraint[:range] && !constraint[:range].cover?(value.to_i)
104
+
105
+ raise RubyRoutes::ConstraintViolation, 'Value not in allowed range'
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constant'
4
+
5
+ module RubyRoutes
6
+ class Route
7
+ # ConstraintValidator: extracted constraint logic.
8
+ #
9
+ # This module provides methods for validating route constraints, including
10
+ # support for regular expressions, procs, hash-based constraints, and built-in
11
+ # validation rules. It also handles timeouts and raises appropriate exceptions
12
+ # for constraint violations.
13
+ module ConstraintValidator
14
+ # Validate all constraints for the given parameters.
15
+ #
16
+ # This method iterates through all constraints and validates each parameter
17
+ # against its corresponding rule.
18
+ #
19
+ # @param params [Hash] The parameters to validate.
20
+ # @return [void]
21
+ def validate_constraints_fast!(params)
22
+ @constraints.each do |key, rule|
23
+ param_key = key.to_s
24
+ next unless params.key?(param_key)
25
+
26
+ validate_constraint_for(rule, key, params[param_key])
27
+ end
28
+ end
29
+
30
+ # Dispatch a single constraint check.
31
+ #
32
+ # This method validates a single parameter against its constraint rule.
33
+ #
34
+ # @param rule [Object] The constraint rule (Regexp, Proc, Symbol, Hash).
35
+ # @param key [String, Symbol] The parameter key.
36
+ # @param value [Object] The value to validate.
37
+ # @return [void]
38
+ def validate_constraint_for(rule, key, value)
39
+ case rule
40
+ when Regexp then validate_regexp_constraint(rule, value)
41
+ when Proc then validate_proc_constraint(key, rule, value)
42
+ when Hash then validate_hash_constraint!(rule, value.to_s)
43
+ else
44
+ validate_builtin_constraint(rule, value)
45
+ end
46
+ end
47
+
48
+ # Handle built-in symbol/string rules via a simple lookup.
49
+ #
50
+ # @param rule [Symbol, String] The built-in constraint rule.
51
+ # @param value [Object] The value to validate.
52
+ # @return [void]
53
+ def validate_builtin_constraint(rule, value)
54
+ method_sym = RubyRoutes::Constant::BUILTIN_VALIDATORS[rule.to_sym] if rule
55
+ send(method_sym, value) if method_sym
56
+ end
57
+
58
+ # Validate a regexp constraint.
59
+ #
60
+ # This method validates a value against a regular expression constraint.
61
+ # It raises a timeout error if the validation takes too long.
62
+ #
63
+ # @param regexp [Regexp] The regular expression to match.
64
+ # @param value [Object] The value to validate.
65
+ # @raise [RubyRoutes::ConstraintViolation] If the value does not match the regexp.
66
+ def validate_regexp_constraint(regexp, value)
67
+ Timeout.timeout(0.1) { invalid! unless regexp.match?(value.to_s) }
68
+ rescue Timeout::Error
69
+ raise RubyRoutes::ConstraintViolation, 'Regex constraint timed out'
70
+ end
71
+
72
+ # Validate a proc constraint.
73
+ #
74
+ # This method validates a value using a proc constraint. It emits a
75
+ # deprecation warning for proc constraints and handles timeouts and errors.
76
+ #
77
+ # @param key [String, Symbol] The parameter key.
78
+ # @param proc [Proc] The proc to call.
79
+ # @param value [Object] The value to validate.
80
+ # @raise [RubyRoutes::ConstraintViolation] If the proc constraint fails or times out.
81
+ def validate_proc_constraint(key, proc, value)
82
+ warn_proc_constraint_deprecation(key)
83
+ Timeout.timeout(0.05) { invalid! unless proc.call(value.to_s) }
84
+ rescue Timeout::Error
85
+ raise RubyRoutes::ConstraintViolation, 'Proc constraint timed out'
86
+ rescue StandardError => e
87
+ raise RubyRoutes::ConstraintViolation, "Proc constraint failed: #{e.message}"
88
+ end
89
+
90
+ # Validate an integer constraint.
91
+ #
92
+ # @param value [Object] The value to validate.
93
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not an integer.
94
+ def validate_int_constraint(value)
95
+ invalid! unless value.to_s.match?(/\A\d+\z/)
96
+ end
97
+
98
+ # Validate a UUID constraint.
99
+ #
100
+ # @param value [Object] The value to validate.
101
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not a valid UUID.
102
+ def validate_uuid_constraint(value)
103
+ validate_uuid!(value)
104
+ end
105
+
106
+ # Validate an email constraint.
107
+ #
108
+ # @param value [Object] The value to validate.
109
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not a valid email.
110
+ def validate_email_constraint(value)
111
+ invalid! unless value.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
112
+ end
113
+
114
+ # Validate a slug constraint.
115
+ #
116
+ # @param value [Object] The value to validate.
117
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not a valid slug.
118
+ def validate_slug_constraint(value)
119
+ invalid! unless value.to_s.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
120
+ end
121
+
122
+ # Validate an alpha constraint.
123
+ #
124
+ # @param value [Object] The value to validate.
125
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not alphabetic.
126
+ def validate_alpha_constraint(value)
127
+ invalid! unless value.to_s.match?(/\A[a-zA-Z]+\z/)
128
+ end
129
+
130
+ # Validate an alphanumeric constraint.
131
+ #
132
+ # @param value [Object] The value to validate.
133
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not alphanumeric.
134
+ def validate_alphanumeric_constraint(value)
135
+ invalid! unless value.to_s.match?(/\A[a-zA-Z0-9]+\z/)
136
+ end
137
+
138
+ # Validate a UUID.
139
+ #
140
+ # This method validates that a value is a properly formatted UUID.
141
+ #
142
+ # @param value [Object] The value to validate.
143
+ # @raise [RubyRoutes::ConstraintViolation] If the value is not a valid UUID.
144
+ def validate_uuid!(value)
145
+ string = value.to_s
146
+ unless string.length == 36 && string.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
147
+ invalid!
148
+ end
149
+ end
150
+
151
+ # Raise a constraint violation.
152
+ #
153
+ # @raise [RubyRoutes::ConstraintViolation] Always raises this exception.
154
+ def invalid!
155
+ raise RubyRoutes::ConstraintViolation
156
+ end
157
+ end
158
+ end
159
+ end